본문 바로가기

테스트 주도 라이브러리 개발

실패하는 검색 테스트부터 작성하고 라인 반복·질의 일치·결과 수집 코드를 단계적으로 구현해 테스트를 통과시킵니다.

로직을 src/lib.rs로 추출하고 인수 모으기와 에러 처리는 src/main.rs에 남겨두었으니, 이제는 코드의 핵심 기능에 대한 테스트를 작성하기 무척 쉽습니다.

커맨드 라인에서 바이너리를 호출할 필요 없이 다양한 인수 값으로 함수를 직접 호출하여 반환 값을 검사해 볼 수 있습니다.

이 절에서는 아래의 단계를 따르는 테스트 주도 개발(Test-Driven Development, TDD) 프로세스를 사용하여 minigrep 프로그램의 검색 로직을 추가해 보도록 하겠습니다.

  1. 실패하는 테스트를 작성하고 실행하여, 여러분이 예상한 이유대로 실패하는지 확인합니다.
  2. 이 새로운 테스트를 통과하기에 충분한 정도의 코드만 작성하거나 수정하세요.
  3. 추가하거나 변경한 코드를 리팩터링하고 테스트가 계속 통과하는지 확인하세요.
  4. 1단계로 돌아가세요!

그저 소프트웨어 작성의 수많은 방식 중 하나일 뿐이지만, TDD는 코드 설계를 주도하는 데 도움이 됩니다.

테스트를 통과하도록 해줄 코드를 작성하기 전에 테스트를 먼저 작성하는 것은 프로세스 전체에 걸쳐 높은 테스트 범위를 유지하는 데 도움을 줍니다.

실제로 파일 내용에서 질의 문자열을 찾아보고 질의와 일치하는 라인의 목록을 생성하는 기능의 구현을 테스트 주도적으로 해볼 것입니다.

이 기능을 search라는 이름의 함수에 추가해보겠습니다.

minigrep 검색 기능을 완성하는 TDD 루프

duct 기대 한 줄→빈 Vec RED→lines/contains/push 최소 구현→GREEN→계약 유지 리팩터링의 테스트 상태 변화이다.

RUST · MINIGREP

검색 계약을 테스트로 고정하고 반복한다

search("duct", contents)가 정확히 "safe, fast, productive."를 반환하도록 요구합니다. 빈 Vec로 시작한 실패가 구현과 리팩터링을 이끕니다.

검색 함수의 반복되는 TDD 루프기대 결과, 의도된 실패, 최소 구현, 통과 확인, 리팩터링을 순회하고 다음 테스트로 돌아간다.1 · 기대 결과duct → 한 줄2 · RED 확인빈 Vec의 실패 이유 확인3 · 최소 구현lines · contains · push4 · GREEN 확인같은 테스트가 통과5 · 리팩터링검색 계약 유지다음 사례의 테스트로 반복
검색 계약을 확인하고 다음 사례로 돌아온다다섯노드다섯간선. 기대결과→의도한실패확인→최소구현→같은테스트통과→계약을유지한리팩터링의순환이다. 마지막왼쪽바깥간선은다음사례의기대결과로돌아오며중간노드를역으로거치지않는다. 성공한한사례만으로모든입력과경계가보장되는것은아니다.1 · 기대 결과duct → 한 줄2 · RED 확인빈 Vec의 실패 이유3 · 최소 구현lines · containspush4 · GREEN 확인같은 테스트의 통과5 · 리팩터링검색 계약 유지다음 사례

1 · 기대 결과 작성

assert_eq!로 일치하는 한 줄만 반환한다고 선언합니다.

2 · RED 확인

빈 Vec 구현의 테스트가 의도한 이유로 실패하는지 확인합니다.

3 · 최소 구현

lines, contains, push로 검색 함수를 구현합니다.

4 · GREEN 확인

같은 테스트가 통과하는지 확인합니다.

5 · 리팩터링 → 다음 테스트

검색 계약을 유지하며 변경하고 다음 사례의 실패 테스트로 돌아갑니다.

CLI · frog

How public, like a frog

일치하는 한 줄을 출력합니다.

CLI · body

일치하는 세 줄을 입력 순서대로 출력합니다.

CLI · monomorphization

일치하는 줄이 없어 결과를 출력하지 않습니다.

단위 테스트의 통과와 실제 CLI의 출력은 각각 확인합니다. cargo test로 계약을 검사하고 cargo run -- QUERY FILE로 프로그램을 실행합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석를 반환하도록 요구합니다. 빈 Vec으로 시작한 실패가 구현과 리팩터링을 이끕니다.
관계·조건 설명다섯 노드와 다섯 간선이다. 기대 결과→의도한 실패 확인→최소 구현→같은 테스트 통과→계약을 유지한 리팩터링의 순환이다. 마지막 왼쪽 바깥 간선은 다음 사례의 기대 결과로 돌아오며 중간 노드를 역으로 거치지 않는다. 성공한 한 사례만으로 모든 입력과 경계가 보장되는 것은 아니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
RED

duct를 찾는 테스트가 빈 결과를 관측한다.

  • 기대: 일치 줄 한 개
  • 실제: 빈 Vec
  • 동작이 없는 구현에서 실패하는 계약을 먼저 남긴다.
최소 구현

lines·contains·push로 계약을 구현한다.

  • 처리: 줄 순회 → 조건 → 누적
  • 산출물: 일치 줄
  • 테스트가 요구한 행동을 최소 코드로 추가한다.
GREEN·정리

테스트 통과 뒤 같은 계약으로 리팩터링한다.

  • 테스트: 기대 결과 충족
  • 제약: 동작 계약 유지
  • 구조 개선 뒤에도 같은 테스트를 다시 통과해야 한다.

minigrep 검색 기능을 완성하는 TDD 루프

duct 기대 한 줄→빈 Vec RED→lines/contains/push 최소 구현→GREEN→계약 유지 리팩터링의 테스트 상태 변화이다.

RUST · MINIGREP

검색 계약을 테스트로 고정하고 반복한다

search("duct", contents)가 정확히 "safe, fast, productive."를 반환하도록 요구합니다. 빈 Vec로 시작한 실패가 구현과 리팩터링을 이끕니다.

검색 함수의 반복되는 TDD 루프기대 결과, 의도된 실패, 최소 구현, 통과 확인, 리팩터링을 순회하고 다음 테스트로 돌아간다.1 · 기대 결과duct → 한 줄2 · RED 확인빈 Vec의 실패 이유 확인3 · 최소 구현lines · contains · push4 · GREEN 확인같은 테스트가 통과5 · 리팩터링검색 계약 유지다음 사례의 테스트로 반복
검색 계약을 확인하고 다음 사례로 돌아온다다섯노드다섯간선. 기대결과→의도한실패확인→최소구현→같은테스트통과→계약을유지한리팩터링의순환이다. 마지막왼쪽바깥간선은다음사례의기대결과로돌아오며중간노드를역으로거치지않는다. 성공한한사례만으로모든입력과경계가보장되는것은아니다.1 · 기대 결과duct → 한 줄2 · RED 확인빈 Vec의 실패 이유3 · 최소 구현lines · containspush4 · GREEN 확인같은 테스트의 통과5 · 리팩터링검색 계약 유지다음 사례

1 · 기대 결과 작성

assert_eq!로 일치하는 한 줄만 반환한다고 선언합니다.

2 · RED 확인

빈 Vec 구현의 테스트가 의도한 이유로 실패하는지 확인합니다.

3 · 최소 구현

lines, contains, push로 검색 함수를 구현합니다.

4 · GREEN 확인

같은 테스트가 통과하는지 확인합니다.

5 · 리팩터링 → 다음 테스트

검색 계약을 유지하며 변경하고 다음 사례의 실패 테스트로 돌아갑니다.

CLI · frog

How public, like a frog

일치하는 한 줄을 출력합니다.

CLI · body

일치하는 세 줄을 입력 순서대로 출력합니다.

CLI · monomorphization

일치하는 줄이 없어 결과를 출력하지 않습니다.

단위 테스트의 통과와 실제 CLI의 출력은 각각 확인합니다. cargo test로 계약을 검사하고 cargo run -- QUERY FILE로 프로그램을 실행합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석를 반환하도록 요구합니다. 빈 Vec으로 시작한 실패가 구현과 리팩터링을 이끕니다.
관계·조건 설명다섯 노드와 다섯 간선이다. 기대 결과→의도한 실패 확인→최소 구현→같은 테스트 통과→계약을 유지한 리팩터링의 순환이다. 마지막 왼쪽 바깥 간선은 다음 사례의 기대 결과로 돌아오며 중간 노드를 역으로 거치지 않는다. 성공한 한 사례만으로 모든 입력과 경계가 보장되는 것은 아니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
RED

duct를 찾는 테스트가 빈 결과를 관측한다.

  • 기대: 일치 줄 한 개
  • 실제: 빈 Vec
  • 동작이 없는 구현에서 실패하는 계약을 먼저 남긴다.
최소 구현

lines·contains·push로 계약을 구현한다.

  • 처리: 줄 순회 → 조건 → 누적
  • 산출물: 일치 줄
  • 테스트가 요구한 행동을 최소 코드로 추가한다.
GREEN·정리

테스트 통과 뒤 같은 계약으로 리팩터링한다.

  • 테스트: 기대 결과 충족
  • 제약: 동작 계약 유지
  • 구조 개선 뒤에도 같은 테스트를 다시 통과해야 한다.

실패하는 테스트 작성하기

프로그램 동작을 확인하기 위해 사용되었던 src/lib.rs와 src/main.rs의 println! 구문들은 이제 더 이상 필요가 없으므로 제거합시다.

그런 다음 10장에서처럼 src/lib.rs에 tests 모듈과 함께 테스트 함수를 추가하세요.

테스트 함수는 search 함수가 가져야 할 동작을 지정합니다.

즉 질의 값과 검색할 텍스트를 입력받아서 텍스트로부터 질의 값을 담고 있는 라인들만 반환하는 것이죠.

예제 11-15는 이러한 테스트를 보여주는데, 아직 컴파일되진 않을 것입니다.

예제 11-15: 구현하고자 하는 search 함수를 위한 실패하는 테스트 만들기
src/lib.rs
#[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));
    }
}

이 테스트는 문자열 "duct"를 검색합니다.

검색하는 텍스트는 세 줄인데, 그중 한 줄만이 "duct"를 가지고 있습니다(앞의 큰따옴표 뒤에 붙은 역슬래시는 이 문자열 리터럴 내용의 앞에 줄 바꿈 문자를 집어넣지 않도록 러스트에게 알려주는 것임을 유의하세요).

search 함수가 반환하는 값은 우리가 예상하는 라인만 가지고 있을 것이라고 단언해 두었습니다.

이 테스트는 아직 컴파일도 되지 않을 것이므로 테스트를 실행시켜서 실패하는 걸 지켜볼 수는 없습니다.

아직 search 함수가 없으니까요!

TDD 원칙에 따라서, 예제 11-16과 같이 항상 빈 벡터를 반환하는 search 함수 정의부를 추가하는 것으로 컴파일과 테스트가 동작하기에 딱 충분한 코드만 집어넣어 보겠습니다.

그러면 테스트는 컴파일되고, 반환된 빈 벡터가 "safe, fast, productive." 라인을 가지고 있는 벡터와 일치하지 않으므로 실패해야 합니다.

예제 11-16: 테스트가 딱 컴파일만 될 정도의 search 함수 정의하기
src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

search의 시그니처에는 명시적 라이프타임 'a가 정의될 필요가 있고 이 라이프타임이 contents 인수와 반환 값에 사용되고 있음을 주목하세요.

9장에서 본 것처럼 라이프타임 매개변수는 어떤 인수의 라이프타임이 반환 값의 라이프타임과 연결되는지를 특정한다는 점을 상기해봅시다.

위의 경우에는 반환된 벡터에 (인수 query 쪽이 아니라) 인수 contents의 슬라이스를 참조하는 문자열 슬라이스가 들어있음을 나타내고 있습니다.

바꿔 말하면, 반환되는 문자열 슬라이스의 라이프타임을 contents의 빌림과 연결한 것입니다. 호출자는 이 참조를 'a보다 오래 사용할 수 없습니다.

이것이 중요합니다!

슬라이스에 의해 참조된 데이터는 그 참조자가 유효한 동안 유효할 필요가 있습니다; 만일 컴파일러가 contents 대신 query의 문자열 슬라이스를 만들고 있다고 가정하면, 안전성 검사는 정확하지 않게 될 것입니다.

라이프타임 명시를 잊어먹고 이 함수의 컴파일을 시도하면, 다음과 같은 E0106 에러를 얻게 됩니다. 진단문의 줄 번호와 도움말 표현은 러스트 도구체인 버전에 따라 달라질 수 있습니다.

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | 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
   |
28 | 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` due to previous error

러스트는 두 인수 중 어떤 쪽이 필요한지 알 가능성이 없고, 따라서 이를 명시적으로 말해줄 필요가 있습니다.

이 진단문의 도움말은 query와 contents를 모두 같은 'a에 묶는 가능한 서명을 제안합니다. 그 서명도 구현을 검사할 수 있지만, 반환된 줄이 검색어까지 빌려야 하는 것처럼 호출자를 더 제한합니다. 여기서는 두 입력의 생략된 수명이 각각 독립적이고 반환값의 원천은 contents 하나이므로, 예제 11-16처럼 query: &str는 그대로 둡니다. 라이프타임 생략 규칙은 두 입력 중 어느 것을 반환하는지 함수 본문을 읽어 선택하지 않습니다.

contents가 모든 텍스트를 가지고 있는 인수이고 이 텍스트에서 일치하는 부분을 반환하고 싶은 것이므로, 라이프타임 문법을 사용해 반환 값과 연결되어야 할 인수는 contents라는 사실을 알고 있습니다.

다른 프로그래밍 언어들은 시그니처에 인수와 반환 값을 연결하도록 요구하지 않습니다만, 이 연습은 시간이 지날수록 더 쉬워질 것입니다.

어쩌면 이 예제를 9장의 ‘라이프타임으로 참조자의 유효성 검증하기’ 절에 있는 예제와 비교하고 싶을지도 모르겠습니다.

search의 계약은 질의 문자열을 새로 만드는 것이 아니라, contents 안에서 조건에 맞는 줄의 슬라이스를 빌려 반환한다는 점입니다.

따라서 테스트의 기대값, 함수 시그니처, 반환 데이터의 원본을 함께 보아야 합니다.

search 함수의 검색과 대여 계약

contents 줄 순회/contains→일치한 원본 &str을 Vec에 누적하는 검색 실행이다. query는 패턴이고 반환 대여의 원천은 contents이다.

RUST · MINIGREP

검색 결과의 참조는 contents에서 빌린다

query는 포함 검사의 패턴입니다. 반환된 줄의 수명 'a는 contents와 연결되며, 결과가 query의 저장 공간을 빌리는 것은 아닙니다.

검색 결과는 contents의 줄을 빌린다query는 비교 패턴이다. contents의 lines가 contains 검사를 통과하면 원본 줄 슬라이스가 Vec에 모인다. 반환 수명은 contents와 연결된다.query: &str패턴: "duct"contents: &'a str원본 텍스트·반환의 원천줄 순회contents.lines()포함 검사line.contains(query)Vec<&'a str>일치한 원본 줄 슬라이스비교 패턴일치반환 참조는 contents에서 빌림
줄은 순회하고 결과는 원문을 빌린다다섯노드다섯관계. query는비교패턴으로포함검사에들어가고contents는줄순회를거쳐검사된다. 일치한원본줄참조만결과Vec에넣는다. 점선은query의비교패턴전달과contents에서반환참조를빌리는관계이며검색순서의다른분기가아니다. 반환수명은contents와연결되고query수명이나새로복사한String소유권과혼동하지않는다. query바깥레일은contents/줄순회노드를통과하지않는다.query: &str비교 패턴: ductcontents: &'a str원본·반환의 원천줄 순회contents.lines()포함 검사line.contains(query)Vec<&'a str>일치한 원본 줄 참조비교 패턴일치원문 대여

query · 비교 패턴

&str 질의어를 line.contains에 전달합니다. 반환 참조의 원천이 아닙니다.

contents → lines → 포함 검사

contents: &'a str를 줄 단위로 빌리고 각 줄이 query를 포함하는지 검사합니다.

일치한 줄 → Vec

Vec<&'a str>에 원본 줄 슬라이스를 저장합니다. contents의 빌림보다 오래 사용할 수 없습니다.

본문의 실제 결과

query: "duct"
result: ["safe, fast, productive."]

저장 비용과 순서

줄 텍스트를 새 String으로 복사하지 않습니다. 결과 Vec의 참조 저장 공간은 별도로 필요하며 원본의 줄 순서를 유지합니다.

실선은 검색 처리, 점선은 비교 패턴과 반환 참조의 원천을 나타냅니다. 라이프타임 표시는 데이터의 실제 수명을 연장하지 않습니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명query는 비교 패턴이다. contents에서 lines로 빌린 각 줄이 contains 검사를 통과하면 원본 줄 슬라이스가 Vec에 모인다. 반환 수명은 contents와 연결된다.
관계·조건 설명다섯 노드와 다섯 관계다. query는 비교 패턴으로 포함 검사에 들어가고 contents는 줄 순회를 거쳐 검사된다. 일치한 원본 줄 참조만 결과 Vec에 넣는다. 점선은 query의 비교 패턴 전달과 contents에서 반환 참조를 빌리는 관계이며 검색 순서의 다른 분기가 아니다. 반환 수명은 contents와 연결되고 query 수명이나 새로 복사한 String 소유권과 혼동하지 않는다. query 바깥 레일은 contents/줄 순회 노드를 통과하지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원본 줄

contents에서 빌린 줄을 순회한다.

  • 반환 원천: contents
  • query 역할: 검색 패턴
  • 반환 참조의 출처는 query가 아니라 원본 텍스트다.
일치 검사

각 줄이 query를 포함하는지 판단한다.

  • true: 원본 &str 누적
  • false: 추가하지 않음
  • 선택된 줄을 복제하지 않고 원본 구간을 보관한다.
반환 목록

일치 참조들의 Vec을 돌려준다.

  • 산출물: Vec<&str>
  • 유효 조건: contents의 유효 범위
  • 원본 수명 안에서만 반환된 줄을 사용할 수 있다.

search 함수의 검색과 대여 계약

contents 줄 순회/contains→일치한 원본 &str을 Vec에 누적하는 검색 실행이다. query는 패턴이고 반환 대여의 원천은 contents이다.

RUST · MINIGREP

검색 결과의 참조는 contents에서 빌린다

query는 포함 검사의 패턴입니다. 반환된 줄의 수명 'a는 contents와 연결되며, 결과가 query의 저장 공간을 빌리는 것은 아닙니다.

검색 결과는 contents의 줄을 빌린다query는 비교 패턴이다. contents의 lines가 contains 검사를 통과하면 원본 줄 슬라이스가 Vec에 모인다. 반환 수명은 contents와 연결된다.query: &str패턴: "duct"contents: &'a str원본 텍스트·반환의 원천줄 순회contents.lines()포함 검사line.contains(query)Vec<&'a str>일치한 원본 줄 슬라이스비교 패턴일치반환 참조는 contents에서 빌림
줄은 순회하고 결과는 원문을 빌린다다섯노드다섯관계. query는비교패턴으로포함검사에들어가고contents는줄순회를거쳐검사된다. 일치한원본줄참조만결과Vec에넣는다. 점선은query의비교패턴전달과contents에서반환참조를빌리는관계이며검색순서의다른분기가아니다. 반환수명은contents와연결되고query수명이나새로복사한String소유권과혼동하지않는다. query바깥레일은contents/줄순회노드를통과하지않는다.query: &str비교 패턴: ductcontents: &'a str원본·반환의 원천줄 순회contents.lines()포함 검사line.contains(query)Vec<&'a str>일치한 원본 줄 참조비교 패턴일치원문 대여

query · 비교 패턴

&str 질의어를 line.contains에 전달합니다. 반환 참조의 원천이 아닙니다.

contents → lines → 포함 검사

contents: &'a str를 줄 단위로 빌리고 각 줄이 query를 포함하는지 검사합니다.

일치한 줄 → Vec

Vec<&'a str>에 원본 줄 슬라이스를 저장합니다. contents의 빌림보다 오래 사용할 수 없습니다.

본문의 실제 결과

query: "duct"
result: ["safe, fast, productive."]

저장 비용과 순서

줄 텍스트를 새 String으로 복사하지 않습니다. 결과 Vec의 참조 저장 공간은 별도로 필요하며 원본의 줄 순서를 유지합니다.

실선은 검색 처리, 점선은 비교 패턴과 반환 참조의 원천을 나타냅니다. 라이프타임 표시는 데이터의 실제 수명을 연장하지 않습니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명query는 비교 패턴이다. contents에서 lines로 빌린 각 줄이 contains 검사를 통과하면 원본 줄 슬라이스가 Vec에 모인다. 반환 수명은 contents와 연결된다.
관계·조건 설명다섯 노드와 다섯 관계다. query는 비교 패턴으로 포함 검사에 들어가고 contents는 줄 순회를 거쳐 검사된다. 일치한 원본 줄 참조만 결과 Vec에 넣는다. 점선은 query의 비교 패턴 전달과 contents에서 반환 참조를 빌리는 관계이며 검색 순서의 다른 분기가 아니다. 반환 수명은 contents와 연결되고 query 수명이나 새로 복사한 String 소유권과 혼동하지 않는다. query 바깥 레일은 contents/줄 순회 노드를 통과하지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원본 줄

contents에서 빌린 줄을 순회한다.

  • 반환 원천: contents
  • query 역할: 검색 패턴
  • 반환 참조의 출처는 query가 아니라 원본 텍스트다.
일치 검사

각 줄이 query를 포함하는지 판단한다.

  • true: 원본 &str 누적
  • false: 추가하지 않음
  • 선택된 줄을 복제하지 않고 원본 구간을 보관한다.
반환 목록

일치 참조들의 Vec을 돌려준다.

  • 산출물: Vec<&str>
  • 유효 조건: contents의 유효 범위
  • 원본 수명 안에서만 반환된 줄을 사용할 수 있다.

이제 테스트를 실행해봅시다.

아래 출력은 실패의 핵심만 남긴 예시입니다. cargo test가 표시하는 빌드 시간, 실행 파일 해시, 경로, 줄 번호와 진단문 형식은 도구체인과 빌드 캐시에 따라 달라질 수 있습니다.

대표 출력
$ cargo test
running 1 test
test tests::one_result ... FAILED

failures:
---- tests::one_result stdout ----
assertion `left == right` failed
  left: `["safe, fast, productive."]`,
 right: `[]`

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

좋습니다.

예상대로 테스트는 실패했습니다.

이제 테스트가 통과되도록 해봅시다!


테스트를 통과하도록 코드 작성하기

현재는 언제나 빈 벡터가 반환되고 있으므로 테스트가 실패하고 있습니다.

이를 고치고 search를 구현하려면 프로그램에서 아래의 단계를 따라야 합니다.

  • 내용물의 각 라인에 대해 반복합니다.
  • 해당 라인이 질의 문자열을 담고 있는지 검사합니다.
  • 만일 그렇다면, 반환하고자 하는 값의 리스트에 추가합니다.
  • 아니라면 아무것도 안 합니다.
  • 매칭된 결과 리스트를 반환합니다.

라인들에 대한 반복을 시작으로 각 단계별로 작업해봅시다.

lines 메서드로 라인들에 대해 반복하기

러스트는 문자열의 라인별 반복을 처리하기 위한 유용한 메서드를 제공하는데, 편리하게도 lines라는 이름이고 예제 11-17에서 보는 바와 같이 동작합니다.

아직 컴파일되지 않음을 주의하세요.

예제 11-17: contents의 각 줄에 대한 반복
src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

lines 메서드는 반복자를 반환합니다.

lines()가 내놓는 line은 원문의 각 줄을 빌린 &str이며 줄 끝 문자는 포함하지 않습니다. \n과 Windows의 \r\n 모두 줄 경계로 처리하므로 "red\r\nblue\r\n"에서는 "red", "blue"를 얻습니다. 단독 \r은 줄 경계가 아니며 그대로 남습니다. 파일의 줄바꿈을 직접 잘라내거나 trim()으로 줄 안의 공백까지 지울 필요는 없습니다. 정확한 경계 규칙은 str::lines를 참고하세요.

반복자에 대해서는 12장에서 더 깊이 다루겠습니다만, 예제 2-5에서 이런 방식의 반복자 사용을 봤었음을 상기해봅시다.

그때는 어떤 컬렉션 안의 각 아이템에 대해 어떤 코드를 실행시키기 위해 for와 함께 반복자를 사용했었지요.

각 라인에서 질의값 검색하기

다음으로는 현재의 라인에 질의 문자열이 들어있는지 검사해보겠습니다.

다행히도 이걸 해주는 contains라는 이름의 유용한 메서드가 문자열에 있습니다!

예제 11-18처럼 search 함수에 contains 메서드 호출을 추가하세요.

아직 컴파일되지는 않음을 주의하세요.

예제 11-18: 라인이 query의 문자열을 포함하는지 알아보기 위한 기능 추가하기
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
        }
    }
}

이 시점에서는 아직 기능을 구축하는 중입니다.

컴파일되기 위해서는 함수 시그니처에 명시한 대로 함수 본문에서 어떤 값을 반환할 필요가 있습니다.

매칭된 라인 저장하기

이 함수를 완성하기 위해서는 반환하고자 하는 매칭된 라인들을 저장할 방법이 필요합니다.

이를 위해서 for 루프 전에 가변 벡터를 만들고 line을 이 벡터에 저장하기 위해 push 메서드를 호출할 수 있습니다.

for 루프 뒤에는 예제 11-19와 같이 이 벡터를 반환합니다.

예제 11-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
}

results.push(line)은 일치한 부분 문자열만 저장하는 것이 아니라 그 부분을 포함한 줄 전체의 참조를 저장합니다. 마지막 results에는 세미콜론이 없으므로 벡터가 함수의 반환값이 됩니다. 줄의 바이트를 새 String으로 복사하지 않아도 결과 참조들을 담는 벡터의 저장 공간은 필요합니다.

이제 search 함수는 query를 담고 있는 라인들만 반환해야 하고 테스트는 통과되어야 합니다.

테스트를 실행해봅시다.

이번에도 전체 로그 대신 단위 테스트 결과를 확인하는 데 필요한 부분만 표시합니다.

대표 출력
$ cargo test
running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

one_result가 통과했으므로 이 입력에서 기대한 한 줄을 반환한다는 사실을 확인했습니다. 일치 없음·여러 일치·빈 입력까지 보장하려면 각각을 구분하는 테스트가 더 필요합니다.

이 시점에서, 동일한 기능을 유지하여 테스트가 계속 통과되도록 유지하면서 이 검색 함수의 구현을 리팩터링할 기회를 고려해 볼 수 있습니다.

이 검색 함수의 코드는 그렇게 나쁘진 않습니다만, 반복자의 몇몇 유용한 기능을 활용하고 있지는 않군요.

12장에서 이 예제로 돌아올 건데, 거기서 반복자에 대해 더 자세히 탐구하고 어떻게 개선할 수 있는지 알아볼 것입니다.

run 함수에서 search 함수 사용하기

이제 search 함수가 작동하고 테스트도 되었으니, run 함수에서 search를 호출할 필요가 있습니다.

search 함수에 config.query 값과 run이 읽어 들인 contents를 넘겨줘야 합니다.

그러면 run은 search가 반환한 각 라인을 출력할 것입니다.

src/lib.rs
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

search로부터 반환된 각 라인에 대해 여전히 for를 사용하여 출력하고 있습니다.

이제 전체 프로그램이 동작해야 합니다!

cargo run --에서 -- 뒤의 값은 Cargo 옵션이 아니라 실행할 바이너리의 인수로 전달됩니다. 아래 예시는 Cargo의 빌드 로그를 생략하고 프로그램의 표준 출력만 보여줍니다. 먼저 에밀리 딕킨슨의 시에서 딱 한 줄만 반환되도록 ‘frog’라는 단어를 넣어 시도해봅시다.

$ cargo run -- frog poem.txt
How public, like a frog

이제 여러 줄과 매칭될 ‘body’ 같은 단어를 입력해 동작 범위를 확인해봅시다.

$ cargo run -- body poem.txt
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

마지막으로, ‘monomorphization’ 같이 이 시의 어디에도 없는 단어를 검색하는 경우 아무 줄도 안 나오는지 확인해봅시다.

$ cargo run -- monomorphization poem.txt

예시 search("ana", "banana\nANA\nbanana\n")를 줄마다 추적하면 첫 줄과 셋째 줄이 선택됩니다. contains는 부분 문자열 포함 여부만 묻고 대소문자를 구분하므로 두 번째 ANA는 선택하지 않습니다. 같은 줄이 두 번 나타나면 두 참조를 순서대로 반환하며 중복을 제거하는 코드도 없습니다. 마지막 줄바꿈 뒤의 별도 빈 줄은 lines가 만들지 않습니다.

작은 경계 테스트를 원 tests 모듈에 붙여 일치 없음과 빈 질의를 나눠 볼 수 있습니다.

원 src/lib.rs의 tests 모듈에 추가
#[test]
fn preserves_order_and_duplicates() {
    assert_eq!(
        vec!["banana", "banana"],
        search("ana", "banana\nANA\nbanana\n")
    );
}

#[test]
fn empty_query_keeps_each_line() {
    assert_eq!(vec!["red", "", "blue"], search("", "red\n\nblue\n"));
    assert!(search("x", "").is_empty());
}

기존 use super::*;가 있는 테스트 모듈 안에 추가하는 코드입니다. 빈 질의는 각 줄에 포함되므로 중간의 빈 줄도 선택하지만, 내용 전체가 비면 반복할 줄이 없습니다. str의 lines·contains의 계약을 코드에 적용한 예상이며 여기서 Cargo를 실행해 새 통과 로그를 얻은 것은 아닙니다.

반환된 &str들은 query가 아니라 contents 안의 줄을 빌립니다. 결과 벡터가 줄들의 새 문자열을 소유하는 것이 아니므로, 원 String을 버린 뒤 그 결과를 계속 쓸 수 없습니다. 검색어만 먼저 버리는 일과 원문을 먼저 버리는 일의 차이를 라이프타임 서명과 함께 읽으세요.

아래 세 테스트도 같은 tests 모듈에 추가할 수 있습니다. 첫 테스트는 원문이 있지만 일치가 없는 경우를 빈 원문과 구별하고, 둘째는 Windows 줄바꿈이 결과에 남지 않는지 확인합니다. 셋째는 검색어 String이 먼저 사라져도 원문이 살아 있으면 결과를 사용할 수 있다는 수명 계약을 코드로 보여줍니다.

원 src/lib.rs의 tests 모듈에 추가
#[test]
fn no_match_in_nonempty_contents() {
    assert!(search("green", "red\nblue\n").is_empty());
}

#[test]
fn windows_lines_exclude_line_endings() {
    assert_eq!(vec!["red"], search("red", "red\r\nRED\r\n"));
}

#[test]
fn query_can_end_before_contents() {
    let contents = String::from("red\nblue\n");
    let results = {
        let query = String::from("red");
        search(&query, &contents)
    };

    assert_eq!(vec!["red"], results);
}

셋째 테스트에서 블록이 끝나면 query가 먼저 해제되지만 results의 줄은 바깥의 contents를 빌립니다. 앞의 컴파일러 도움말처럼 두 인수를 모두 &'a str로 바꾸면 이 호출의 결과도 검색어의 짧은 수명으로 제한되어 블록 밖 사용이 거부됩니다. 예상 결과와 거부 이유를 비교하는 예시이며, 이 문서에 새 Cargo 실행 결과를 추가한 것은 아닙니다.

독립 연습: 질의 "red"와 내용 "red\nRED\nred\n"의 반환 순서·개수를 적어 보세요. 질의를 ""로 바꾼 경우도 계산합니다. 별도 블록에서 검색어 String만 만들고 블록 밖의 contents를 검색한 뒤 결과를 블록 밖에서 쓰는 경우와, contents 자체를 블록 안에서 만들어 결과를 밖에서 쓰는 경우 중 원 서명이 허용하는 것을 고르세요.

선택된 줄과 빌린 원문의 수명 확인하기

대소문자를 구분하는 질의는 첫째·셋째 red의 두 참조를 같은 순서로 반환합니다. 빈 질의는 red, RED, red의 세 줄을 반환하고 마지막 줄바꿈 뒤의 원소를 추가하지 않습니다. 검색어만 블록 안에 있는 경우에는 반환 수명이 검색어에 묶이지 않아 남아 있는 원문으로 결과를 쓸 수 있습니다. 원문이 블록 안에서 끝난다면 그 줄을 빌린 결과를 밖에서 사용할 수 없습니다. 반환 벡터를 얻었다는 사실과 원문 바이트의 소유권을 얻었다는 사실을 구별합니다.

TDD 흐름에서는 “테스트가 무엇을 보장하는가”와 “구현이 어떤 소유권/라이프타임 조건을 만족하는가”를 함께 봐야 합니다. 단위 테스트는 검색 결과를 고정하고, 함수 시그니처는 반환 슬라이스의 원본을 contents로 고정하며, 실제 실행은 한 줄·여러 줄·일치 없음 사례를 확인합니다.

고전적인 도구의 축소판을 만들면서 애플리케이션 구조화 방법을 살펴보았습니다.

또한 파일 입출력과 라이프타임, 테스트, 커맨드 라인 파싱에 대해서도 약간씩 배웠습니다.

이 프로젝트를 정리하기 위해서, 환경 변수를 가지고 동작시키는 방법과 표준 에러로 출력하는 방법을 간략하게 보려고 하는데, 둘 모두 커맨드 라인 프로그램을 작성할 때 유용합니다.