테스트 작성과 유지보수는 지루하고 실수하기 쉽지만, 스냅샷 테스트를 활용하면 CLI부터 컴파일러, 웹 애니메이션까지 다양한 영역에서 더 즐겁고 효율적으로 테스트할 수 있다.
URL: https://giacomocavalieri.me/writing/testing-can-be-fun-actually
테스트 작성은 지루합니다. 더 나쁜 건, 테스트를 유지보수하는 일은 지루한 데다 실수하기도 쉽다 는 점입니다. 비극적인 사실은, 테스트가 우리가 작성할 수 있는 코드 중에서도 가장 가치 있는 축에 든다는 겁니다.
그래서 오늘은 테스트 도구상자에 추가할 수 있는, 재미있고도 심각하게 과소평가된 기법을 보여드리려 합니다. 테스트 작성과 유지보수를 훨씬 더 즐겁게 만들어 줄 수 있는 기법: 스냅샷 테스트(snapshot testing) 입니다. 이 기법은 어디에나 적용할 수 있고, 이를 증명하기 위해 CLI부터 Rust로 작성된 컴파일러, 그리고… 웹 애니메이션까지 실제 사례를 살펴보겠습니다!
이 글은 제 발표 "Supercharge your Tests with Snapshot Testing" 을 바탕으로 했습니다. 관심이 있다면 YouTube에서 시청할 수 있어요.
커맨드라인 애플리케이션(CLI)을 작성하고 있다고 해봅시다. 회사 내부에서 귀찮은 잡무를 자동화하려고 만든 작은 도구일 수도 있고, 멋진 오픈소스 프로젝트일 수도 있죠. 이 애플리케이션에서 가장 중요한 부분은 무엇일까요? 도움말 텍스트(help text)! 사용자가 길을 잃었을 때 가장 먼저 보게 되는 것이므로, 제대로 만드는 것이 아주 중요합니다. 익숙한가요? 이렇게 중요한 만큼 테스트도 해야겠죠:
pub fn help_text_test() {
assert cli.help_text() == "
usage: lucysay [-m message] [-f file]
-m, --message the message to be printed
-f, --file a file to read the message from
-h, --help show this help text
"
}
도움말 텍스트 문자열이 기대하는 메시지와 일치하는지 확인합니다. 일반적인 유닛 테스트 assert를 쓰는 이상 피할 수 없습니다. 저 문자열을 전부 직접 타이핑해야 합니다.
더 큰 문제는, 도움말 텍스트가 바뀌면(예: 더 많은 플래그와 기능이 들어간 v2를 출시한다면) 저 리터럴 문자열을 직접 찾아 편집해서 테스트가 다시 통과하게 만들어야 한다는 겁니다. 꽤나 귀찮은 작업이죠. 이렇게 장황한 assert가 들어간 테스트를 작성하고 유지보수하는 일은 결코 재미있지 않습니다. 지루하고 반복적이며 실수하기 쉬운 과정입니다.
여기서 스냅샷 테스트가 등장합니다. 엘리베이터 피치는 간단합니다: 테스트 작성에 집중하면, 스냅샷 테스트 라이브러리가 기대값(expected values)은 자동으로 처리해 준다.
실제로는 어떻게 보일까요? 스냅샷 테스트는 이렇게 생겼습니다:
// 이 예제에서는 birdie 라이브러리를 사용합니다.
// gleam 프로젝트에 추가하려면 다음을 실행하세요:
// `gleam add birdie --dev`
import birdie
pub fn help_text_test() {
cli.help_text()
|> birdie.snap(title: "testing the help text")
}
테스트 대상 함수는 그대로지만, 이번에는 결과를 birdie.snap 함수에 전달합니다(일단 title은 신경 쓰지 마세요. 나중에 설명합니다).
좀 마술처럼 보이죠. 기대값이 어디에도 없는데, 라이브러리가 어떻게 테스트를 실패시켜야 할지 알까요? 테스트를 실행하면 무슨 일이 일어날까요? 해봅시다!
> gleam test
panic test/example_test.gleam:9
test: example_test.usage_text_test
info: Birdie snapshot test failed
Finished in 0.006 seconds
1 tests, 1 failures
별로 흥미롭진 않네요. 테스트가 실패했습니다. 하지만 실패한 테스트와 함께 새로운 출력이 보일 겁니다. 바로 여기서 마법이 일어납니다:
── new snapshot ────────────────────────────────────────────
title: testing the help text
hint: run `gleam run -m birdie` to review the snapshots
────────┬───────────────────────────────────────────────────
1 + usage: lucysay [-m message] [-f file]
2 +
3 + -m, --message the message to be printed
4 + -f, --file a file to read the message from
5 + -h, --help show this help text
────────┴───────────────────────────────────────────────────
테스트가 실패하면서 함수가 실제로 만들어낸 출력을 보여줍니다. 힌트를 읽어보면 사람이 루프에 들어와야 한다는 걸 알 수 있어요. 스냅샷을 리뷰 해야 합니다. 라이브러리의 힌트대로 리뷰해 봅시다:
> gleam run -m birdie
Reviewing 1st out of 1
── new snapshot ────────────────────────────────────────────
title: testing the help text
file: ./test/cli.gleam
────────┬───────────────────────────────────────────────────
... here you'll see the snapshot from earlier
────────┴───────────────────────────────────────────────────
a accept accept the new snapshot
r reject reject the new snapshot
s skip skip the snapshot for now
d hide diff toggle snapshot diff
내용을 읽어보면 우리가 원하는 그대로죠. 모든 옵션이 나열되어 있고 오타도 없습니다. 스냅샷을 accept합니다. 이제 테스트를 실행할 때마다 성공할 겁니다… 물론 함수의 출력이 바뀌지 않는 한 말이죠.
내부 동작은 놀라울 만큼 단순합니다. 스냅샷을 accept하면 스냅샷 테스트 라이브러리가 그 내용을 파일에 저장하고, 함수가 항상 그 값을 생산하는지 확인합니다. 출력이 바뀌면 테스트가 실패하고, 우리는 다시 스냅샷을 리뷰해야 합니다.
직접 해보세요! 아래 프롬프트에 정말로 입력할 수 있습니다.
바뀌는 assert를 다루는 과정에서 좋은 점은, 스냅샷 테스트 라이브러리가 변경이 있을 때 엄청나게 도움이 될 수 있다는 것입니다. 예를 들어, 여기서 사용하는 birdie는 버전 관리 시스템처럼 유용한 diff 뷰를 보여줍니다:
── mismatched snapshot ─────────────────────────────────────
title: testing the help text
hint: run `gleam run -m birdie` to review the snapshots
────────┬───────────────────────────────────────────────────
1 - usage: lucysay [-m message] [-f file]
1 + usage: lucysay [-m message]
2 + prints a cute message to standard output
3 │
4 | -m, --message the message to be printed
4 - -f, --file a file to read the message from
5 | -h, --help show this help text
────────┴───────────────────────────────────────────────────
정말 좋은 개발자 경험입니다. 무엇이 바뀌었는지 한눈에 보고 리뷰할 수 있어요. 그리고 가장 좋은 점은, 우리는 이미 git이나 jj 같은 버전 관리 시스템에서 수년간 이 워크플로를 써왔다는 겁니다. 작동 방식도 익숙하고, 느낌도 친숙합니다:
assert를 직접 찾아서 수동으로 업데이트할 필요가 없습니다. 이제 명시적 assert 관리에 발목 잡히지 않고 테스트 자체에 집중할 수 있습니다.
맞아요. 첫 번째 예제에서는 약간 꼼수를 썼습니다. 테스트한 함수는 이미 문자열을 만들고, 그건 스냅샷으로 저장하고 diff하기가 쉽습니다. 하지만 현실에서 테스트하고 싶은 함수들은 대개 그렇지 않아요! 리스트, 딕셔너리, 복잡한 객체, 이상한 데이터 컬렉션을 다뤄야 할 수도 있죠. 그럼 어떡할까요?
문자열로 바꾸세요.
그럼 테스트하고 싶은 데이터마다 to_string 함수를 만들어야 하나요?
네! 그리고 사실 그게 좋습니다!
각각의 데이터를 마법 같은 만능 to_string으로 문자열로 바꾸고 싶어지기 쉽습니다. 하지만 각 스냅샷의 내용이 무엇이어야 하는지 의도적으로 정하는 것이야말로 이 기법의 성패를 가릅니다. Gleam 컴파일러를 작업하면서, 좋은 스냅샷 테스트가 얼마나 큰 차이를 만들 수 있는지 보여주는 훌륭한 예를 만났습니다.
Gleam 컴파일러는 Rust로 작성된 거대한 소프트웨어입니다. 또한 5000개가 넘는 유닛 테스트로 꽤 철저하게 테스트되어 있습니다. 그중 3000개 이상이 스냅샷 테스트입니다(스냅샷 테스트가 규모 있게 동작하냐고요? 네, 충분히 됩니다)!
여기서 집중할 부분은 Language Server 구현입니다. 저는 호버링(마우스를 올렸을 때) 툴팁이 제대로 동작하는지 확인하는 스냅샷 테스트를 살펴보고 있었습니다.
Language Server Protocol은 IDE가 코드의 특정 부분 위에 마우스를 올렸을 때 작은 툴팁을 표시할 수 있게 해줍니다. 커서로 함수 위에 올리면 그 함수의 문서를 보여주는 기능이죠. 혹은 변수의 추론된 타입을 보여주기도 합니다.
pub fn main() -> Nil { let a_variable = 11 // ^^^ 여기에 커서를 올리면, 저는 // 변수의 타입을 알려주는 유용한 // 툴팁을 기대합니다. 이 경우 `Int`죠. }굉장히 유용하기 때문에, 올바른 정보를 보여주는지와 더불어(아주 중요) 호버된 요소 위에 정확히 위치하는지도 반드시 보장해야 합니다.
Language Server는 IDE가 툴팁을 렌더링하는 데 필요한 모든 정보를 담은 복잡한 데이터 구조를 만들어 냅니다. 그걸 테스트하려는 거죠. 처음에는 더 나은 아이디어가 없어서, 기본 디스플레이 함수로 전체 데이터 구조를 문자열로 바꿔 스냅샷으로 저장했습니다. 테스트는 이랬습니다:
pub fn hovering_variable_test() {
hover(over: find_position_of("a_variable"), code: "
pub fn main() -> Nil {
let a_variable = 11
Nil
}
")
|> hover_data_to_string
|> birdie.snap(title: "hovering over a variable shows its type")
}
스냅샷은 이렇게 생겼죠:
── new snapshot ────────────────────────────────────────────
title: hovering over a variable shows its type
hint: run `gleam run -m birdie` to review the snapshots
────────┬───────────────────────────────────────────────────
1 + Hover(
2 + range: Some(Range(start: 24, end: 33)),
3 + contents: Scalar(
4 + String("```gleam\nInt\n```"),
5 + ),
6 + )
────────┴───────────────────────────────────────────────────
질문 하나. 이건 좋은 스냅샷 테스트일까요? 확실히 우리가 신경 쓰는 정보를 모두 담고 있고, 구현이 맞는지 판단하기에도 충분하긴 합니다.
하지만 구현이 맞다는 걸 쉽게 알 수 있게 해주나요?
툴팁을 표시하는 범위(range)가 잘못되었을 수도 있는데, 저는 그걸 알아채지 못할 겁니다! 원본 문자열에서 바이트를 하나하나 세어, 정말로 변수 전체를 호버하는지 고통스럽게 확인해야 해요. 고통스러운 유닛 테스트 assert를, 고통스러운 리뷰용 스냅샷으로 바꿔치기한 셈이죠.
스냅샷을 어떻게 만들지 고민할 때, 문자열 포맷을 정성 들여 만드는 것이 테스트를 단순하고 재미있게 만드는 핵심입니다. 이 예제에서 제가 구현한 출력은 이런 모습입니다:
── new snapshot ────────────────────────────────────────────
title: hovering over a variable shows its type
hint: run `gleam run -m birdie` to review the snapshots
────────┬───────────────────────────────────────────────────
1 + pub fn main() -> Nil {
2 + let a_variable = 11
3 + ↑▔▔▔▔▔▔▔▔▔
4 + Nil
6 + }
5 +
6 + ----- Hover content:
7 + ```gleam
8 + Int
9 + ```
────────┴───────────────────────────────────────────────────
제가 말하는 ‘재미있는 스냅샷’이 이런 겁니다. 한눈에 봐도 툴팁이 호버된 변수에 완벽히 정렬되어 있고, 내용도 아래에 깔끔하게 렌더링됩니다.
이런 읽기 좋은 스냅샷 덕분에 Gleam 컴파일러에 기여되는 새로운 코드를 리뷰할 때 저와 Louis, Surya가 절약한 시간이 얼마나 큰지 아무리 강조해도 부족합니다. 솔직히 말해, 이런 시각적으로 좋은 출력과 마주하면 테스트를 쓰는 게 정말 재미있다고까지 말할 수 있어요!
이제 이것이 얼마나 강력하고, 또 얼마나 유연하게 변형될 수 있는지 감이 오셨을 겁니다. 제 스냅샷 테스트 라이브러리를 공개한 뒤로, 사람들이 창의적으로 사용하는 모습을 보고 늘 놀라고 있습니다.
마지막 예제는 멋진 프론트엔드 프레임워크를 만드는 친구 Hayleigh가 보여준 것입니다. 컴포넌트 라이브러리의 일부로 Hayleigh는 트위닝(tweening) 함수도 구현했습니다. 트위닝 함수는 다양한 애니메이션의 기반이 되며, 어떤 함수를 쓰느냐에 따라 선형, ease-in/ease-out, cubic 등 다양한 움직임을 만들 수 있습니다…
핵심에서 트위닝 함수는 꽤 단순합니다(물론 수학은 확실히 쉽지 않지만요): 값을 입력으로 받고, 그 값의 최소/최대 범위를 받아서, 보간(interpolated)된 새 값을 반환합니다:
fn tween_cubic_in_out(
t value: Float,
between min: Float,
and max: Float
) -> Float {
todo as "some tricky math in here..."
}
프론트엔드 개발을 해봤다면 이 보간 함수가 어떤 움직임을 만들어낼지 감이 있을지도 모릅니다. 하지만 직접 만져보며 확인할 수도 있죠. 아래에서 슬라이더를 움직여 보세요. 보간된 값이 어떻게 변하는지 볼 수 있습니다:
let x =
tween_cubic_in_out(
t: 0.00,
between: 0.0,
and: 1.0
)
assert x == 0.00
이런 수학은 꽤 까다롭고 틀리기 쉬우니, 당연히 구현이 올바른지 테스트해야 합니다. 일반적인 유닛 테스트를 쓴다면, 몇몇 알려진 입력값에 대한 기대 출력을 확인하겠죠:
pub fn tween_cubic_in_out_test() {
assert tween_cubic_in_out(0.0, between: 0.0, and: 1.0) == 0.0
assert tween_cubic_in_out(0.5, between: 0.0, and: 1.0) == 0.5
assert tween_cubic_in_out(0.7, between: 0.0, and: 1.0) == 0.89
}
그런데 이건 좋은 테스트일까요? 누군가 이 멋진 라이브러리를 보고 새 애니메이션 함수 tween_sine_in_out을 기여했다고 해봅시다. 테스트도 추가했네요. 친절하군요!
pub fn tween_sine_in_out_test() {
assert tween_sine_in_out(0.0, between: 0.0, and: 1.0) == 0.0
assert tween_sine_in_out(0.5, between: 0.0, and: 1.0) == 0.7
assert tween_sine_in_out(0.7, between: 0.0, and: 1.0) == 0.9
}
이 테스트를 보기만 해도, 새 함수 구현이 맞다고 확신할 수 있나요? 별로 그렇지 않습니다. 다음 중 하나를 해야 하죠:
한눈에 이 테스트는 함수의 정확성에 대해 별 이야기를 해주지 않습니다. 리뷰는 고된 일이 됩니다. 그럼 스냅샷 테스트가 여기서 어떻게 도움이 될까요? 이렇게 추상적인 것에도 쓸 수 있을까요? 이미 하고 있습니다! 슬라이더를 만져보고 그려지는 형태를 눈으로 보는 것만으로도 트위닝 함수가 어떻게 동작할지 꽤 잘 알 수 있잖아요. 그럼 테스트에서도 그렇게 하면 되지 않을까요? Hayleigh가 떠올린 방식은 이렇습니다:
pub fn tween_cubic_in_out_test() {
tween_cubic_in_out
|> plot_function
|> birdie.snap(title: "cubic tween with in and out easing")
}
스냅샷은 어떻게 생겼을까요?
── new snapshot ────────────────────────────────────────────
title: cubic tween with in and out easing
hint: run `gleam run -m birdie` to review the snapshots
────────┬───────────────────────────────────────────────────
1 + ◍◍◍◍◍◍
2 + ◍◍
3 + ◍◍
4 + ◍
5 + ◍
6 +
7 + ◍
8 +
9 + ◍
10 +
11 + ◍
12 +
13 + ◍
14 +
15 + ◍
16 +
17 +
18 + ◍
19 +
20 + ◍
21 +
22 + ◍
23 +
24 + ◍
25 +
26 + ◍
27 +
28 + ◍
29 + ◍
30 + ◍◍
31 + ◍◍
32 + ◍◍◍◍◍◍
────────┴───────────────────────────────────────────────────
정말 기발하지 않나요? 한눈에 극값에서 ease-in/out하는 cubic 곡선이라는 걸 쉽게 알 수 있습니다. 저라면, 테스트 스위트에 무작위처럼 흩어진 Float들이 잔뜩 있는 것보다 이 테스트가 함수가 올바르게 동작한다는 확신을 훨씬 더 줍니다.
이제 모든 테스트를 스냅샷으로 바꾸기 전에, 이 도구를 효과적으로 사용하는 방법에 대한 인사이트를 몇 가지 공유하겠습니다.
스냅샷에는 제목이 필요하고, 리뷰할 때 그 제목이 표시된다는 점을 눈치채셨을 겁니다. 그리고 이건 사실 아주 중요한 요소입니다. 무엇을 봐야 하는지 알려주거든요. 스냅샷 이름이 "some test"라면, 보고 있는 게 뭔지 알아내기 어렵지 않나요? 반대로 제목이 "'wibble' 변수가 밑줄로 표시된다"라면, 본문에서 정확히 무엇이 일어나야 하는지 알 수 있고 wibble 변수에 밑줄이 없다면 거절(reject)하면 됩니다.
유닛 테스트는 이름에서부터 답이 나옵니다. 자체 포함된 작은 단위여야 하죠. 스냅샷 테스트도 마찬가지입니다. 한 스냅샷에 너무 많은 걸 쑤셔 넣지 마세요.
이유는, 누군가가 +10293/-5011짜리 PR 리뷰를 요청하면 우리가 느끼는 공포와 같습니다. 거대한 스냅샷 하나를 리뷰하는 것보다, 작고 명확하게 정의된 스냅샷 여러 개를 리뷰하는 편이 훨씬 쉽습니다… 동료들이 고마워할 거예요!
정확한 크기는 상황마다 다르지만, 경험칙으로 스냅샷이 10~50줄이면 대체로 괜찮습니다. 더 길어지기 시작하면 테스트를 리팩터링해야 한다는 신호일 수 있습니다(맞아요, 테스트도 리팩터링과 사랑이 필요합니다). 어쩌면 한 번에 너무 많은 것을 assert 하려는 걸지도 모르니, 스냅샷 하나를 더 집중된 스냅샷 몇 개로 나누는 게 좋습니다.
가장 중요한 항목입니다. 새롭고 반짝이는 도구를 배우면, 최고의 도구라고 생각해서 어디에나 쓰고 싶어지죠. 하지만 언제 쓰지 말아야 하는지 를 아는 것이 더 중요합니다. 스냅샷 테스트의 경우:
일반적인 유닛 테스트 assert로도 충분히 좋을 수 있습니다.
이제 테스트 실력을 한 단계 끌어올릴 새로운 도구를 얻으셨길 바랍니다. 제 생각에 스냅샷 테스트는 범죄적일 정도로 덜 쓰이고 있고, 브라우저 통합 테스트에서 잘못 사용되는 바람에 평판도 그리 좋지 않습니다(참고로 UI 테스트에 대해 더 알고 싶다면 이 발표가 도움이 될지도 모릅니다).
제 경험에서 스냅샷 테스트는 생명의 은인 같은 존재였습니다. 이제는 기본적으로 스냅샷 테스트를 먼저 선택하고, 위에서 언급한 경우에만 유닛 테스트로 돌아갑니다… 그리고 다시는 돌아보지 않을 겁니다!
끝까지 읽어주셔서 정말 감사합니다. 그리고 피드백을 공유해 준 모든 멋진 분들께도 감사드립니다. 댓글을 남기거나 이 글을 BlueSky에 공유해 주세요. 시간을 내어 교정해 주고 제안을 공유해 준 Cian Synnott에게도 큰 감사 인사를 전합니다. 최고예요!
이 글은 사람(저!)이 정성껏 작성하고 코딩했습니다. 꽤 시간이 걸리는 일이기도 하고, 저를 후원하고 싶다면 GitHub Sponsors를 통해 할 수 있습니다.