본문 바로가기

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

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

로직을 src/lib.rs로 옮기고 인수 수집과 오류 처리는 src/main.rs에 남겨 두었으므로, 이제 코드의 핵심 기능을 테스트하기가 훨씬 쉬워졌습니다. 명령줄에서 바이너리를 실행하지 않고도 여러 인수로 함수를 직접 부르고 반환값을 확인할 수 있습니다.

이 글에서는 테스트 주도 개발(test-driven development, TDD) 과정으로 minigrep에 검색 로직을 더합니다. TDD는 언어와 상관없이 쓰는 소프트웨어 개발 기법으로 TypeScript 교재의 테스트 주도 개발 (TDD)에서도 다루며, 다음 단계를 따릅니다.

단계할 일
1실패하는 테스트를 쓰고 실행해, 예상한 까닭으로 실패하는지 확인합니다.
2새 테스트를 통과할 만큼만 코드를 쓰거나 고칩니다.
3방금 더하거나 고친 코드를 리팩터링하고 테스트가 계속 통과하는지 확인합니다.
41단계로 돌아갑니다.

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·정리

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

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

먼저 search("duct", contents)가 정확히 "safe, fast, productive." 한 줄을 돌려주어야 한다고 assert_eq!로 적습니다. 빈 Vec을 돌려주는 구현으로 테스트가 의도한 까닭으로 실패하는지 확인한 뒤, lines와 contains, push로 검색을 구현하고 같은 테스트가 통과하는지 확인합니다. 그다음 테스트가 정한 동작을 지키며 코드를 고치고, 다음 사례의 실패하는 테스트로 돌아갑니다. 테스트 하나가 통과했다고 모든 입력이 올바르게 처리된다는 보장은 없으므로, 사례를 늘려 가며 이 과정을 되풀이합니다.

실패하는 테스트 작성하기

테스트 작성 방법에서 했듯, src/lib.rs에 테스트 함수가 든 tests 모듈을 더합니다. 테스트 함수는 search 함수가 가져야 할 동작을 정합니다. 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 함수가 없으므로 이 테스트를 실행해 실패하는 것을 볼 수도 없습니다. 컴파일조차 되지 않습니다. 그래서 언제나 빈 벡터를 돌려주는 search 함수를 테스트가 컴파일되고 실행될 만큼만 정의합니다. 빈 벡터는 "safe, fast, productive." 줄이 든 벡터와 같지 않으므로, 테스트는 컴파일되고 실패할 것입니다.

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

search의 시그니처에 명시적인 라이프타임 'a를 정의하고, 이 라이프타임을 contents 인수와 반환값에 쓴 점을 눈여겨보세요. 라이프타임과 참조 유효성에서 보았듯, 라이프타임 매개변수는 어느 인수의 라이프타임이 반환값의 라이프타임과 이어지는지를 나타냅니다. 여기서는 반환되는 벡터가 query 인수가 아니라 contents 인수의 슬라이스를 가리키는 문자열 슬라이스를 담는다는 뜻입니다.

다시 말해, search 함수가 돌려주는 데이터는 search에 contents 인수로 넘긴 데이터만큼 오래 살아 있을 것이라고 러스트에게 알려 줍니다. 이것이 중요한 까닭은, 슬라이스가 가리키는 데이터는 그 참조가 유효한 동안 유효해야 하기 때문입니다. 컴파일러가 문자열 슬라이스를 contents가 아니라 query의 것으로 여긴다면, 안전 검사를 잘못하게 됩니다. 아래 그림은 search가 무엇을 받아 무엇을 빌려 돌려주는지를 단계별로 보여 줍니다.

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의 유효 범위
  • 원본 수명 안에서만 반환된 줄을 사용할 수 있다.

query는 각 줄에 들어 있는지 비교할 패턴으로 line.contains에 넘어갈 뿐, 반환되는 참조의 원천이 아닙니다. contents는 contents.lines()로 한 줄씩 빌려 검사하고, 검색어가 든 원본 줄의 슬라이스만 Vec<&'a str>에 담습니다. 그래서 결과는 contents의 대여보다 오래 쓸 수 없습니다. 줄의 텍스트를 새 String으로 복사하지는 않지만, 결과 Vec이 참조들을 담을 공간은 따로 필요하며, 결과는 원본의 줄 순서를 그대로 따릅니다. 라이프타임 표시는 이 관계를 컴파일러에게 알릴 뿐 데이터가 실제로 살아 있는 기간을 늘리지는 않습니다.

라이프타임 표시를 빼먹고 이 함수를 컴파일하면 다음 오류가 납니다. 이때 search는 src/lib.rs의 30번째 줄에 있습니다.

error[E0106]: missing lifetime specifier
  --> src/lib.rs:30:51
   |
30 | 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
   |
30 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

러스트는 둘 중 어느 인수가 필요한지 알 수 없으므로, 우리가 분명히 알려 주어야 합니다. 도움말은 두 인수를 모두 같은 'a에 묶는 시그니처를 제안합니다. 그 시그니처도 컴파일은 되지만, 그러면 반환된 줄이 검색어까지 빌리고 있다고 여겨져 쓸데없이 제약이 늘어납니다. contents가 모든 텍스트를 가진 인수이고 그 텍스트에서 일치하는 부분을 돌려주려는 것이므로, 라이프타임 문법으로 반환값과 이어야 하는 인수는 contents입니다.

다른 프로그래밍 언어에서는 시그니처에서 인수와 반환값을 이어 주지 않아도 되지만, 이 연습은 시간이 지날수록 쉬워질 것입니다. 이 예제를 라이프타임과 참조 유효성의 예제와 비교해 보아도 좋습니다. 이제 테스트를 실행합니다.

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

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----

thread 'tests::one_result' (6017887) panicked at src/lib.rs:46:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

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

error: test failed, to rerun pass `--lib`

아직 쓰지 않는 매개변수 query와 contents에 대한 unused variable 경고도 함께 나오지만 생략했습니다. 예상대로 테스트가 실패했습니다. left는 기대한 한 줄이고, right는 지금 구현이 돌려준 빈 벡터입니다. 위치 46:9는 src/lib.rs에서 assert_eq!가 있는 줄입니다. 이제 테스트가 통과하게 만듭니다.

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

지금은 언제나 빈 벡터를 돌려주므로 테스트가 실패합니다. 이것을 고치고 search를 구현하려면, 프로그램은 다음 단계를 따라야 합니다.

단계할 일
1내용의 각 줄을 반복합니다.
2그 줄에 검색어가 들어 있는지 확인합니다.
3들어 있다면 반환할 값의 목록에 그 줄을 더합니다.
4들어 있지 않다면 아무것도 하지 않습니다.
5일치한 결과의 목록을 돌려줍니다.

줄을 반복하는 것부터 한 단계씩 진행합니다.

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

러스트에는 문자열을 한 줄씩 반복하는 편리한 메서드 lines가 있습니다. 이 코드는 아직 컴파일되지 않습니다.

src/lib.rs (컴파일되지 않음)
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // 각 줄로 무언가 하기
    }
}

lines 메서드는 반복자를 돌려줍니다. 반복자는 반복자 기반 데이터 처리에서 자세히 다룹니다. 다만 for 루프로 컬렉션의 각 요소에 대해 코드를 실행할 때 반복자를 쓰는 방법은 이미 보았습니다. 여기서도 for 루프와 반복자로 컬렉션의 각 항목에 코드를 실행합니다. lines는 줄 끝의 \n이나 \r\n을 떼고 각 줄을 돌려주므로, Windows 형식의 줄바꿈도 결과에 남지 않습니다.

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

다음으로 지금 줄에 검색어가 들어 있는지 확인합니다. 다행히 문자열에는 이 일을 해 주는 contains라는 편리한 메서드가 있습니다. 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) {
            // 줄로 무언가 하기
        }
    }
}

지금은 기능을 쌓아 가는 중입니다. 코드가 컴파일되려면 함수 시그니처에 적은 대로 본문에서 값을 돌려주어야 합니다.

매칭된 라인 저장하기

함수를 마무리하려면 돌려줄 일치한 줄들을 저장할 방법이 필요합니다. 그래서 for 루프 앞에 가변 벡터를 만들고 push 메서드로 line을 벡터에 저장합니다. for 루프가 끝나면 벡터를 돌려줍니다.

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
}

이제 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; finished in 0.00s

라이브러리의 유닛 테스트 뒤에 src/main.rs의 유닛 테스트와 문서 테스트 부분이 이어지지만, 둘 다 running 0 tests이므로 생략했습니다. 테스트가 통과했으니 동작한다는 것을 확인했습니다.

이 시점에서, 테스트를 통과하는 상태를 유지하면서 같은 기능이 되도록 search 함수의 구현을 리팩터링할 기회를 생각해 볼 수 있습니다. 이 함수의 코드는 나쁘지 않지만 반복자의 쓸모 있는 기능을 그다지 살리지 못합니다. I/O 프로젝트 개선에서 이 예제로 돌아와 반복자를 자세히 살펴보고 개선하는 방법을 봅니다.

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

search 함수가 동작하고 테스트도 되었으니, run 함수에서 search를 불러야 합니다. config.query 값과 run이 파일에서 읽은 contents를 search 함수에 넘깁니다. 그러면 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(())
}

여전히 for 루프로 search가 돌려준 줄을 하나씩 출력합니다. 검색 결과만 출력되도록, 전에 진행 상황을 확인하려고 src/main.rs에 넣어 둔 Searching for와 In file 두 println!도 지웁니다.

src/main.rs
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

이제 프로그램 전체가 동작할 것입니다. 먼저 에밀리 디킨슨의 시에서 정확히 한 줄을 돌려줄 단어 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`

훌륭합니다. 앞의 그림에서처럼 cargo test로는 search가 정한 동작을 지키는지 확인하고, cargo run -- QUERY FILE로는 실제 프로그램의 출력을 따로 확인했습니다. 고전적인 도구의 작은 판을 직접 만들면서, 애플리케이션을 구조화하는 방법을 많이 배웠습니다. 파일 입출력과 라이프타임, 테스트, 명령줄 해석도 조금씩 배웠습니다.

이 프로젝트를 마무리하기 위해, 환경 변수로 동작을 바꾸는 방법과 표준 에러로 출력하는 방법을 간단히 봅니다. 둘 다 명령줄 프로그램을 쓸 때 쓸모가 있습니다.

정리

  • TDD는 실패하는 테스트를 먼저 쓰고, 통과할 만큼만 구현하고, 통과를 유지하며 리팩터링하는 과정을 되풀이합니다.
  • 테스트가 컴파일되도록 빈 벡터를 돌려주는 search를 먼저 두고, 테스트가 예상한 까닭으로 실패하는지 확인합니다.
  • search<'a>(query: &str, contents: &'a str) -> Vec<&'a str>는 결과가 contents를 빌린다는 것을 나타내며, 라이프타임을 빼면 E0106 오류가 납니다.
  • lines로 줄을 반복하고 contains로 검색어가 들어 있는지 확인해, 일치한 원본 줄의 슬라이스를 push로 모읍니다.
  • run이 search의 결과를 한 줄씩 출력하며, 유닛 테스트의 통과와 실제 프로그램의 출력은 따로 확인합니다.

연습 문제

1. 무엇을 돌려줄까

search("ana", "banana\nANA\nbanana\n")와 search("", "red\n\nblue\n")는 각각 무엇을 돌려주나요?

해설 보기

첫 호출은 contains가 대소문자를 구분하므로 ANA를 고르지 않고, 첫째와 셋째 줄인 ["banana", "banana"]를 같은 순서로 돌려줍니다. 둘째 호출은 빈 문자열이 모든 문자열에 들어 있으므로 가운데의 빈 줄까지 세 줄을 모두 고른 ["red", "", "blue"]를 돌려줍니다. 마지막 줄바꿈 뒤에는 줄이 더 생기지 않으므로, lines가 빈 줄을 하나 더 만들지는 않습니다.

2. 결과를 얼마나 오래 쓸 수 있을까

다음 코드는 컴파일되나요? search의 시그니처를 오류 도움말대로 query와 contents 모두 &'a str로 바꾸면 어떻게 되나요?

let contents = String::from("red\nblue\n");
let results = {
    let query = String::from("red");
    search(&query, &contents)
};
assert_eq!(vec!["red"], results);
해설 보기

지금의 시그니처에서는 결과가 contents만 빌리므로, 블록이 끝나며 query가 해제되어도 results를 계속 쓸 수 있어 컴파일됩니다. 두 인수를 모두 'a로 묶으면 결과가 query도 빌린 것으로 여겨지므로, 블록 밖에서 results를 쓰는 곳에서 query가 충분히 오래 살지 않는다는 오류가 납니다. 그래서 반환값과 이어 줄 인수는 실제로 결과가 가리키는 contents 하나로 정합니다.

다음 글 환경 변수 활용에서는 환경 변수로 대소문자를 구분하지 않는 검색을 고를 수 있게 합니다.