테스트
지난 수십 년간 소프트웨어 개발 분야에서 사람들은 한 가지 진실을 발견했습니다: 테스트하지 않은 소프트웨어는 대부분 동작하지 않는다. (많은 사람들이 더 나아가서 “테스트한 소프트웨어도 대체로 동작하지 않는다“고 말합니다. 그래도 여기서 우리는 모두 낙관론자입니다. 그렇죠?) 따라서, 프로그램이 예상대로 동작하는지 확인하려면 테스트를 하는 것이 현명합니다.
한 가지 쉬운 방법은 README
파일에
프로그램이 어떻게 동작해야 하는
작성하는 것입니다.
그러면 새로운 버전을 출시할 준비가 됐을 때
README
파일을 읽어보고 프로그램이
예상대로 동작하는지 확인할 수 있습니다.
이때 프로그램이 잘못된 입력에 대해 어떻게 동작해야 하는지도
적어두면 더 엄격히 검증할 수 있습니다.
또 다른 그럴듯한 아이디어가 있습니다:
바로 코드를 작성하기 전에 README
를 작성하는 것입니다.
테스트 자동화
모든 것이 멋져 보이지만, 이 모든 것을 수동으로 해야 할까요? 수동으로 하면 시간이 오래 걸릴 수 있습니다. 또한 많은 사람들이 컴퓨터에게 자신을 위해 뭔가를 수행하도록 지시하는 것을 좋아합니다. 이제 테스트를 자동화하는 방법에 대해 살펴보겠습니다.
러스트는 빌트인 테스트 프레임워크를 갖추고 있습니다. 첫 테스트를 작성해봅시다:
fn answer() -> i32 {
42
}
#[test]
fn check_answer_validity() {
assert_eq!(answer(), 42);
}
위의 코드 스니펫을 아무 파일에나 작성하고
cargo test
를 실행하면 테스트가 시작됩니다.
여기서 핵심은 #[test]
속성입니다.
이 속성은 빌드 시스템이 위와 같은 함수를 찾고
테스트로 실행해 패닉이 일어나지 않음을
검증할 수 있도록 해줍니다.
우리는 테스트를 어떻게 작성하는지 살펴봤고, 이제 무엇을 테스트할지 알아내야 합니다. 앞서 봤듯이 함수를 어서션(assertion)하는 테스트를 작성하기는 매우 쉽습니다. 하지만 CLI 애플리케이션에는 보통 여러 함수가 있습니다. 심지어 그 함수들이 사용자의 입력을 받고, 파일을 읽으며, 출력을 냅니다.
코드를 테스트 가능하게 만들기
기능 테스트에는 두 가지의 보완적 접근법이 있습니다: 하나는 전체 애플리케이션을 빌드할 때 사용되는 작은 단위를 테스트하는 “유닛 테스트“입니다. 다른 하나는 “외부에서” 최종 애플리케이션을 테스트하는 “블랙 박스 테스트” 또는 “통합 테스트“입니다. 유닛 테스트부터 시작해 봅시다.
무엇을 테스트할지 알아내기 위해서는
프로그램의 기능을 살펴봐야 합니다.
grrs
의 주요 기능은 주어진 패턴에 일치하는 라인을 출력하는 것입니다.
따라서 정확히 이 기능 에 대한 테스트를 작성해 봅시다.
우리는 로직의 가장 중요한 부분이 동작하는지 확인해야 하고,
이를 둘러싼 어떠한 설정 코드(예를 들어 CLI 인자를 다루는 코드)에도
의존하지 않는 방식으로 테스트를 해야 합니다.
grrs
의 첫 구현에서
우리는 main
함수에 아래 코드 블록을 추가했습니다:
// ...
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
슬프게도, 이 코드는 테스트하기 어렵습니다. 일단 코드가 메인 함수 안에 있기 때문에 호출하는 것부터 쉽지 않습니다. 이 문제는 별도 함수로 코드 조각을 분리함으로써 쉽게 개선할 수 있습니다:
#![allow(unused)] fn main() { fn find_matches(content: &str, pattern: &str) { for line in content.lines() { if line.contains(pattern) { println!("{}", line); } } } }
이제 테스트에서 함수를 호출할 수 있습니다:
#[test]
fn find_a_match() {
find_matches("lorem ipsum\ndolor sit amet", "lorem");
assert_eq!( // uhhhh
이렇게 할 수 있을까요?
여기서 find_matches
는 stdout
, 즉, 터미널에 직접 결과를 출력합니다.
테스트에서는 그 출력을 쉽게 포착할 수가 없습니다.
구현한 뒤에 테스트를 작성하면 함수가 사용되는 맥락 안에 함수를 완전히
통합하게 되기 때문에 이런 문제가 자주 일어납니다.
좋습니다, 위 코드를 어떻게 테스트 가능하게 바꿀 수 있을까요?
어떻게든 출력을 포착할 방법이 필요할 것입니다.
러스트의 표준 라이브러리는 I/O (input/output)을 다루기 위한
몇 가지 깔끔한 추상화를 제공하며,
여기서는 std::io::Write
를 사용해 볼 것입니다.
std::io::Write
는 문자열 뿐만 아니라, stdout
까지 비롯한
쓰기 동작을 추상화해주는 트레잇입니다.
러스트에서 “트레잇“을 처음 들어봤다면,
아마 마음에 들 것입니다.
트레잇은 러스트의 강력한 기능 중 하나로,
자바의 인터페이스나 하스켈의 타입 클래스와 비슷하다고 생각할 수 있습니다.
(여러분이 더 친숙한 쪽으로 생각해 보세요.)
이들은 서로 다른 타입이 공유하는 동작을 추상화할 수 있도록 해줍니다.
트레잇을 사용하는 코드는 개념을
매우 범용적이면서 유연한 방식으로 표현할 수 있게 됩니다.
다만 이로 인해 코드를 읽기 어려워지기도 합니다.
겁먹지는 마세요. 수년간 러스트를 사용해 온 사람들도
범용적인 코드를 바로 작성하지는 못합니다.
그럴 때는 구체적인 용도를 생각해 보는 것이 도움 됩니다.
예를 들어, 우리는 “무언가에 쓴다“라는 동작을
추상화하고자 합니다.
std::io::Write
를 구현(“impl”)하는 타입에는
터미널의 표준 출력,
파일,
메모리 버퍼,
TCP 네트워크 커넥션 등이 있습니다.
(std::io::Write
문서에서 스크롤을 내려보면
“Implementors” 목록을 볼 수 있습니다.)
이러한 배경 지식을 바탕으로,
우리의 함수가 세 번째 파라미터를 받도록 수정해 봅시다.
파라미터는 Write
를 구현하는 타입이어야 합니다.
이를 통해
테스트에 간단한 문자열을 전달하고,
그 값을 어서션할 수 있게 됩니다.
아래는 수정된 find_matches
코드입니다:
fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
for line in content.lines() {
if line.contains(pattern) {
writeln!(writer, "{}", line);
}
}
}
새로운 파라미터는 mut writer
입니다.
(즉, “writer“라고 부르는 가변적인 값입니다.)
이 파라미터의 타입은 impl std::io::Write
이며,
이를 “Write
트레잇을 구현하는 타입을 위한 플레이스홀더“라고
읽을 수 있습니다.
또한 앞서 작성한 println!(…)
이
writeln!(writer, …)
으로 바뀌었습니다.
println!
은 writeln!
과 똑같이 동작하지만
항상 표준 출력을 사용합니다.
이제 출력을 아래와 같이 테스트할 수 있습니다:
#[test]
fn find_a_match() {
let mut result = Vec::new();
find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
assert_eq!(result, b"lorem ipsum\n");
}
이 함수를 애플리케이션 코드에 사용하기 위해서는
main
의 find_matches
에 세 번째 파라미터로
&mut std::io::stdout()
를 전달하도록 변경해야 합니다.
아래는 이전 챕터에서 살펴본 메인 함수가
앞서 작성한 find_matches
함수를 사용하는 예시입니다:
fn main() -> Result<()> {
let args = Cli::parse();
let content = std::fs::read_to_string(&args.path)
.with_context(|| format!("could not read file `{}`", args.path.display()))?;
find_matches(&content, &args.pattern, &mut std::io::stdout());
Ok(())
}
앞서 하나의 코드 조각을 쉽게 테스트 가능하도록 만드는 방법에 대해 알아봤습니다. 우리는,
- 애플리케이션의 핵심 부분 중 하나를 명확히 알게 되었고,
- 그 부분을 자체적인 함수로 추출했으며,
- 코드를 더욱 유연하게 만들었습니다.
처음에는 그저 코드를 테스트 가능하게 만드는 것이 목표였지만, 결과적으로 매우 자연스럽고, 재사용 가능한 러스트 코드를 얻었습니다. 놀라운 일이죠!
코드를 라이브러리와 바이너리 타겟으로 쪼개기
여기서 하나를 더 해볼 수 있습니다.
지금까지는 모든 코드를 src/main.rs
파일에만 작성했습니다.
이는 현재 프로젝트가 하나의 바이너리로 만들어진다는 것을 의미합니다.
그런데 코드를 라이브러리로도 만들 수 있습니다. 이렇게요:
find_matches
함수를 새로 만든src/lib.rs
파일에 넣습니다.fn
앞에pub
을 추가합니다. 따라서pub fn find_matches
가 됩니다.src/main.rs
에서find_matches
를 지웁니다.fn main
에서find_matches
를 호출하는 부분 앞에grrs::
를 붙입니다. 따라서grrs::find_matches(…)
가 됩니다. 이것은 우리가 방금 작성한 라이브러리의 함수를 사용한다는 의미입니다!
러스트가 프로젝트를 다루는 방식은 매우 유연하며, 크레이트의 라이브러리 부분에 어떤 내용을 작성할지 초기에 생각해보는 것이 좋습니다. 예를 들어, 애플리케이션에 특정된 로직을 위한 라이브러리를 먼저 작성한 다음, 그 라이브러리를 다른 라이브러리처럼 CLI에서 사용할 수 있습니다. 또는, 프로젝트에 여러 바이너리가 있는 경우 크레이트의 라이브러리 부분에 공통 기능을 작성할 수 있습니다.
CLI 애플리케이션을 실행해서 테스트하기
지금까지 우리는 애플리케이션의 비즈니스 로직을
테스트하기 위해 노력했고, 주요 로직이
find_matches
함수임을 알아냈습니다.
이러한 과정은 매우 가치있으며,
잘 테스트된(well-tested) 코드 베이스를 향한
훌륭한 첫 걸음이기도 합니다.
(이런 종류의 테스트를 보통 “유닛 테스트“라고 부릅니다.)
그러나 바깥 세상과 상호작용하기 위해 작성한 코드는 아직 테스트하지 않았습니다. 만약 메인 함수를 작성했는데 실수로 사용자가 제공하는 경로 인자 대신, 하드 코딩된 문자열을 사용하는 코드를 남겨뒀다고 상상해보세요. 이에 대한 테스트도 작성해야 합니다! (이 수준의 테스트를 주로 “통합 테스트” 또는 “시스템 테스트“라고 부릅니다.)
tests/cli.rs
.
우리는 여전히 함수를 작성하고
그 함수를 #[test]
로 표시하고 있습니다.
이때는 함수 안에서 무슨 일이 일어나는지만이 중요합니다.
예를 들어, 프로젝트의 메인 바이너리를
사용해 일반적인 프로그램처럼 실행하려 합니다.
여러분은 이에 대한 테스트를 새로운 디렉토리에
새 파일(tests/cli.rs
)로 집어 넣을 것입니다:
다시 돌아가서,
grrs
은 파일의 문자열을 찾는 작은 도구입니다.
우리는 앞서 일치하는 문자열을 찾는 기능에 대한 테스트를 작성했습니다.
이제 테스트할 수 있는 다른 것들에 대해 생각해봅시다.
여기 몇 가지가 있습니다.
- 파일이 존재하지 않을 때는 무슨 일이 일어나나요?
- 일치하는 문자열이 없는 경우 무엇이 출력되나요?
- 인자 하나(또는 둘 다)를 전달하지 않으면 프로그램이 에러와 함께 종료되나요?
이들은 모두 유효한 테스트 케이스입니다. 추가로, 우리는 “행복한 경우“에 대한 테스트도 케이스도 하나 작성해야 합니다. 가령, 최소 하나의 일치하는 문자열을 찾고, 그 라인을 출력하는 동작에 대한 테스트 케이스가 있습니다.
이런 종류의 테스트를 쉽게 작성하기 위해,
assert_cmd
크레이트를 사용할 것입니다.
assert_cmd
는 메인 바이너리를 실행하고,
실행된 바이너리가 어떻게 동작하는지 보여주는
간결한 도구들을 제공합니다.
더 나아가,
assert_cmd
가 테스트할 어서션을
작성할 때 도움을 받기 위하여
(그리고 훌륭한 에러 메시지를 위하여)
predicates
크레이트도 추가할 것입니다.
이 두 디펜던시는 메인 리스트에 추가하지 않고
Cargo.toml
파일의 “개발 디펜던시“에
추가합니다.
두 디펜던시가 크레이트를 개발할 때만 필요하고,
실제로 사용할 때는 필요하지 않기 때문입니다.
[dev-dependencies]
assert_cmd = "2.0.11"
predicates = "3.0.3"
많은 준비가 필요해 보이죠.
그래도 tests/cli.rs
파일을 만들며
시작해 봅시다:
use assert_cmd::prelude::*; // 명령에 메서드 추가
use predicates::prelude::*; // 어서션 작성에 사용
use std::process::Command; // 프로그램을 실행
#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("foobar").arg("test/file/doesnt/exist");
cmd.assert()
.failure()
.stderr(predicate::str::contains("could not read file"));
Ok(())
}
이 테스트는 앞서 작성한 다른 테스트들과 마찬가지로
cargo test
로 실행할 수 있습니다.
처음 실행할 때는 Command::cargo_bin("grrs")
가
메인 바이너리를 컴파일해야 하기 때문에
시간이 조금 걸릴 수 있습니다.
테스트 파일 생성하기
앞서 작성한 테스트는 입력 파일이 존재하지 않을 때 프로그램이 출력하는 에러 메시지만을 체크했습니다. 이것이 중요한 테스트이기는 하지만, 가장 중요하지는 않습니다. 이제 파일에서 찾은 일치 문자열을 실제로 출력하는지 테스트해보겠습니다!
우리가 아는 내용으로 채워진 파일이 필요합니다. 그렇다면 우리는 프로그램이 무엇을 반환해야 하는지 예상할 수 있고, 코드에서 그 예상을 체크할 수 있습니다. 프로젝트에 우리가 임의의 내용으로 채운 파일을 하나 추가하고, 이 파일을 테스트에 사용하는 방식을 생각해볼 수 있습니다. 또 다른 방식은 테스트에서 임시 파일을 생성할 수도 있습니다. 튜토리얼을 위해 후자의 접근 방식을 살펴보도록 하겠습니다. 임시 파일을 생성하는 방식은 유연하며, 파일을 변경하는 프로그램을 테스트할 때도 테스트가 잘 동작하도록 만들 수 있습니다.
임시 파일을 만들기 위해,
assert_fs
크레이트를 사용할 것입니다.
Cargo.toml
파일의 dev-dependencies
에
추가해봅시다:
assert_fs = "1.0.13"
아래는 임시 파일 (경로를 알 수 있도록 이름이 지정된 파일)을
먼저 만들고 임의의 텍스트로 채운 다음,
프로그램을 실행하여 올바를 출력을 얻을 수 있는지
확인하는 새로운 테스트 케이스입니다.
이 테스트 케이스를 다른 케이스 아래에 작성할 수 있습니다.
file
이 (함수의 끝에서) 스코프를 벗어나면,
실제 임시 파일이 자동으로 삭제됩니다.
use assert_fs::prelude::*;
#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
let file = assert_fs::NamedTempFile::new("sample.txt")?;
file.write_str("A test\nActual content\nMore content\nAnother test")?;
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("test").arg(file.path());
cmd.assert()
.success()
.stdout(predicate::str::contains("test\nAnother test"));
Ok(())
}
무엇을 테스트할 것인가?
통합 테스트를 작성하는 것이 분명 재밌을 수도 있지만, 테스트를 작성하는 데 드는 시간이 애플리케이션의 동작을 변경할 때만큼 필요할 수도 있습니다. 시간을 현명하게 사용하기 위해서는 스스로에게 무엇을 테스트해야 하는지 물어야 합니다.
사용자에게 노출되는 모든 종류의 행동에 대한 통합 테스트를 작성하는 것이 일반적으로 좋은 생각이기는 합니다. 다만 모든 엣지 케이스를 커버할 필요는 없습니다. 보통 다양한 유형에 대한 예시를 만드는 것으로 충분하며, 엣지 케이스는 유닛 테스트에 맡기면 됩니다.
여러분이 적극적으로 통제할 수 없는 것에 대해 테스트를 집중하지 않는 것도 좋은 생각입니다.
--help
가 출력하는 세세한 내용을 테스트하는 것은 좋은 생각이 아닙니다.
대신 특정 요소가 노출되는지만 테스트하는 것이 좋습니다.
프로그램의 특성에 따라,
더 많은 테스트 기법을 추가해볼 수도 있습니다.
예를 들어,
프로그램의 일부를 추출해
모든 엣지 케이스를 찾아내려고 애쓰는 동시에
유닛 테스트로 많은 양의 예시 케이스를 작성하고 있는
자신을 발견한다면, proptest
를 살펴봐야 합니다.
만약 임의의 파일을 사용하고, 그 파일을 파싱하는 프로그램을 만든다면
엣지 케이스에서 버그를 찾기 위해 fuzzer를 사용해볼 수 있습니다.