V 실전: 테스트와 디버깅 — 버그 없는 V 코드
코드를 짰으면 테스트를 해야 한다. "이 코드가 정말로 원하는 대로 동작하는가?"를 확인하지 않으면, 나중에 코드를 수정할 때 어디가 깨졌는지 알 수가 없다. V는 별도의 테스트 라이브러리 없이, 언어에 내장된 테스트 프레임워크를 제공한다. 이번 편에서는 테스트의 기초부터 실전 적용까지 배운다.
왜 테스트를 해야 할까?
비유하자면, 테스트 없이 코드를 짜는 건 안전벨트 없이 운전하는 것과 같다. 지금 당장은 괜찮을 수 있지만, 사고가 나면(버그가 생기면) 손해가 크다.
테스트가 주는 이점을 정리하면 이렇다.
| 테스트가 없으면 | 테스트가 있으면 |
|---|---|
| 코드 수정 후 "뭔가 깨졌나?" 불안 | 수정 후 테스트 실행 → 즉시 확인 |
| 버그를 사용자가 발견 | 버그를 개발자가 미리 발견 |
| "어디서부터 깨졌지?" 추적 어려움 | 테스트가 정확한 위치를 알려줌 |
| 리팩토링이 두려움 | 테스트가 안전망이 되어 자신감 있게 수정 |
1단계: assert — 가장 기본적인 검증
assert는 "이 조건이 참이어야 한다"는 선언이다. 조건이 거짓이면 프로그램이 즉시 멈추면서 어디서 실패했는지 알려준다.
기본 사용법
fn add(a int, b int) int {
return a + b
}
fn main() {
result := add(2, 3)
assert result == 5 // ✅ 참 → 통과
assert result > 0 // ✅ 참 → 통과
assert result != 0 // ✅ 참 → 통과
// assert result == 100 // ❌ 거짓 → 프로그램 멈춤!
println('모든 assert 통과!')
}
assert가 실패하면 이런 에러 메시지가 나온다.
main.v:10: FAIL: assert result == 100
Left value: 5
Right value: 100
실패 지점의 파일명, 줄 번호, 기대한 값과 실제 값을 모두 보여준다. "어디서 뭐가 잘못됐는지" 한눈에 확인할 수 있다.
커스텀 메시지
assert에 설명 메시지를 추가할 수 있다. 테스트가 많아지면 어떤 검증이 실패했는지 빠르게 파악하는 데 도움이 된다.
fn is_valid_port(port int) bool {
return port > 0 && port <= 65535
}
fn main() {
assert is_valid_port(8080), '8080은 유효한 포트여야 한다'
assert is_valid_port(443), '443은 유효한 포트여야 한다'
assert !is_valid_port(-1), '음수는 유효하지 않아야 한다'
assert !is_valid_port(70000), '65535 초과는 유효하지 않아야 한다'
println('포트 검증 테스트 통과!')
}
2단계: _test.v — V의 테스트 파일
assert를 main() 안에 넣는 건 임시 방편이다. 실전에서는 별도의 테스트 파일에 테스트를 작성한다. V에서는 파일 이름이 _test.v로 끝나면 테스트 파일로 인식된다.
프로젝트 구조
my_project/
├── calculator.v ← 실제 코드
└── calculator_test.v ← 테스트 코드
실제 코드 (calculator.v)
module main
fn add(a int, b int) int {
return a + b
}
fn subtract(a int, b int) int {
return a - b
}
fn multiply(a int, b int) int {
return a * b
}
fn divide(a f64, b f64) !f64 {
if b == 0 {
return error('0으로 나눌 수 없습니다')
}
return a / b
}
테스트 코드 (calculator_test.v)
module main
// 테스트 함수 이름은 반드시 'test_'로 시작해야 한다
fn test_add() {
assert add(2, 3) == 5
assert add(0, 0) == 0
assert add(-1, 1) == 0
assert add(-5, -3) == -8
}
fn test_subtract() {
assert subtract(5, 3) == 2
assert subtract(0, 0) == 0
assert subtract(3, 5) == -2
}
fn test_multiply() {
assert multiply(3, 4) == 12
assert multiply(0, 100) == 0
assert multiply(-2, 3) == -6
}
fn test_divide() {
// ! 가 포함된 함수는 or 로 처리
result := divide(10.0, 2.0) or {
assert false, '나누기가 실패하면 안 된다'
return
}
assert result == 5.0
// 0으로 나누면 에러가 발생해야 한다
if _ := divide(10.0, 0.0) {
assert false, '0으로 나누기가 성공하면 안 된다'
}
}
테스트 실행
# 특정 파일의 테스트 실행
v test calculator_test.v
# 현재 디렉토리의 모든 테스트 실행
v test .
# 결과 예시
---- Testing... ----
[OK] calculator_test.v
---- Summary: ----
All 4 tests passed.
테스트 파일의 규칙
| 규칙 | 설명 | 예시 |
|---|---|---|
| 파일명 | _test.v로 끝나야 한다 | math_test.v, utils_test.v |
| 함수명 | test_로 시작해야 한다 | test_add(), test_divide() |
| 모듈 | 테스트 대상과 같은 모듈 | module main |
| 검증 | assert로 결과 확인 | assert result == 5 |
| 실행 | v test 파일명 | v test . (전체) |
일반 빌드(v run)에서 _test.v 파일은 자동으로 제외된다. 테스트 코드가 프로덕션 코드에 섞이지 않는다.
3단계: 테이블 기반 테스트
같은 함수를 여러 입력으로 테스트하고 싶을 때, 하나하나 assert를 쓰면 반복적이다. 테이블 기반 테스트는 입력과 기대 결과를 배열로 묶어서 반복 테스트하는 패턴이다.
기본 방식 (반복적)
fn test_is_even_basic() {
assert is_even(0) == true
assert is_even(1) == false
assert is_even(2) == true
assert is_even(3) == false
assert is_even(-2) == true
assert is_even(100) == true
// ...수십 개의 assert가 반복된다
}
테이블 기반 (깔끔)
fn is_even(n int) bool {
return n % 2 == 0
}
struct TestCase {
input int
expected bool
}
fn test_is_even_table() {
// 테스트 케이스를 배열로 정의
tests := [
TestCase{ input: 0, expected: true },
TestCase{ input: 1, expected: false },
TestCase{ input: 2, expected: true },
TestCase{ input: 3, expected: false },
TestCase{ input: -2, expected: true },
TestCase{ input: -3, expected: false },
TestCase{ input: 100, expected: true },
TestCase{ input: 999, expected: false },
]
// 모든 케이스를 반복 테스트
for t in tests {
result := is_even(t.input)
assert result == t.expected, 'is_even(${t.input}): 기대값 ${t.expected}, 실제값 ${result}'
}
}
테이블 기반 테스트의 장점을 정리하면 이렇다.
| 장점 | 설명 |
|---|---|
| 가독성 | 입력과 기대값이 한눈에 보인다 |
| 확장성 | 새 테스트 케이스 추가 = 배열에 한 줄 추가 |
| 일관성 | 모든 케이스가 같은 방식으로 테스트된다 |
| 에러 메시지 | 실패 시 "어떤 입력에서 실패했는지" 정확히 나온다 |
Go 커뮤니티에서 특히 많이 쓰이는 패턴인데, V에서도 동일하게 적용할 수 있다.
4단계: 실전 테스트 — 이전 편 코드에 적용
이전 편들에서 만든 코드에 실제로 테스트를 추가해보자.
1편 파일 정리기의 카테고리 분류 테스트
module main
// 원본 함수 (1편에서 만든 것)
fn get_category(ext string) string {
return match ext {
'.jpg', '.png', '.gif', '.svg', '.webp' { 'Images' }
'.mp3', '.wav', '.flac', '.aac' { 'Music' }
'.mp4', '.avi', '.mkv', '.mov' { 'Videos' }
'.pdf', '.doc', '.docx', '.txt', '.md' { 'Documents' }
'.zip', '.tar', '.gz', '.rar', '.7z' { 'Archives' }
else { 'Others' }
}
}
// 테스트 --- get_category_test.v 에 저장
struct CategoryTest {
ext string
expected string
}
fn test_get_category() {
tests := [
// 이미지
CategoryTest{ ext: '.jpg', expected: 'Images' },
CategoryTest{ ext: '.png', expected: 'Images' },
CategoryTest{ ext: '.svg', expected: 'Images' },
// 음악
CategoryTest{ ext: '.mp3', expected: 'Music' },
CategoryTest{ ext: '.flac', expected: 'Music' },
// 비디오
CategoryTest{ ext: '.mp4', expected: 'Videos' },
CategoryTest{ ext: '.mkv', expected: 'Videos' },
// 문서
CategoryTest{ ext: '.pdf', expected: 'Documents' },
CategoryTest{ ext: '.md', expected: 'Documents' },
// 압축
CategoryTest{ ext: '.zip', expected: 'Archives' },
// 기타 — 알 수 없는 확장자
CategoryTest{ ext: '.xyz', expected: 'Others' },
CategoryTest{ ext: '.abc', expected: 'Others' },
CategoryTest{ ext: '', expected: 'Others' },
]
for t in tests {
result := get_category(t.ext)
assert result == t.expected, '확장자 "${t.ext}": 기대 "${t.expected}", 실제 "${result}"'
}
}
테이블 기반 테스트로 13개 확장자를 한 번에 검증한다. 새로운 확장자 카테고리를 추가할 때, 테스트에도 한 줄만 추가하면 된다.
2편 JSON 설정 관리의 파싱 테스트
module main
import json
struct AppConfig {
mut:
app_name string = 'my-app'
port int = 8080
debug bool = false
}
fn test_json_decode_full() {
data := '{"app_name": "test-app", "port": 3000, "debug": true}'
config := json.decode(AppConfig, data) or {
assert false, 'JSON 파싱이 실패하면 안 된다'
return
}
assert config.app_name == 'test-app'
assert config.port == 3000
assert config.debug == true
}
fn test_json_decode_partial() {
// 일부 필드만 있는 JSON — 기본값이 채워져야 한다
data := '{"app_name": "partial-app"}'
config := json.decode(AppConfig, data) or {
assert false, 'JSON 파싱이 실패하면 안 된다'
return
}
assert config.app_name == 'partial-app'
assert config.port == 8080, 'port 기본값이 8080이어야 한다'
assert config.debug == false, 'debug 기본값이 false여야 한다'
}
fn test_json_decode_empty_object() {
// 빈 JSON 객체 — 모든 필드가 기본값이어야 한다
data := '{}'
config := json.decode(AppConfig, data) or {
assert false, 'JSON 파싱이 실패하면 안 된다'
return
}
assert config.app_name == 'my-app'
assert config.port == 8080
assert config.debug == false
}
fn test_json_decode_invalid() {
// 잘못된 JSON — 에러가 발생해야 한다
data := '이것은 JSON이 아닙니다'
if _ := json.decode(AppConfig, data) {
assert false, '잘못된 JSON이 성공하면 안 된다'
}
// ← 여기에 도달하면 정상 (에러가 발생했으니까)
}
fn test_json_encode_roundtrip() {
// 인코딩 → 디코딩으로 원래 값이 보존되는지 검증
original := AppConfig{
app_name: '라운드트립'
port: 9999
debug: true
}
encoded := json.encode(original)
decoded := json.decode(AppConfig, encoded) or {
assert false, '라운드트립 파싱이 실패하면 안 된다'
return
}
assert decoded.app_name == original.app_name
assert decoded.port == original.port
assert decoded.debug == original.debug
}
여기서 중요한 패턴 두 가지를 짚어보자.
"에러가 발생해야 하는" 테스트:
fn test_json_decode_invalid() {
if _ := json.decode(AppConfig, data) {
assert false, '잘못된 JSON이 성공하면 안 된다'
}
}
성공하면 assert false로 테스트를 실패시킨다. "이 코드는 실패해야 정상"이라는 역발상 테스트다.
라운드트립 테스트:
원본 → 인코딩(JSON 문자열) → 디코딩(구조체) → 원본과 비교
데이터가 변환을 거쳐도 원래 값이 보존되는지 검증한다. 직렬화/역직렬화 코드에서 필수적인 테스트 패턴이다.
5단계: 테스트 도우미 함수
테스트 코드에서도 함수를 만들어 재사용할 수 있다. 반복되는 검증 패턴을 도우미 함수로 추출하면 테스트가 더 깔끔해진다.
module main
// 테스트 도우미: 두 배열이 같은지 검증
fn assert_arrays_equal(got []int, expected []int) {
assert got.len == expected.len, '길이 다름: ${got.len} vs ${expected.len}'
for i, v in got {
assert v == expected[i], '인덱스 ${i}: 기대 ${expected[i]}, 실제 ${v}'
}
}
fn sort_numbers(arr []int) []int {
mut sorted := arr.clone()
sorted.sort()
return sorted
}
fn test_sort_numbers() {
assert_arrays_equal(sort_numbers([3, 1, 2]), [1, 2, 3])
assert_arrays_equal(sort_numbers([]), [])
assert_arrays_equal(sort_numbers([1]), [1])
assert_arrays_equal(sort_numbers([5, 4, 3, 2, 1]), [1, 2, 3, 4, 5])
}
fn filter_positives(arr []int) []int {
return arr.filter(it > 0)
}
fn test_filter_positives() {
assert_arrays_equal(filter_positives([1, -2, 3, -4, 5]), [1, 3, 5])
assert_arrays_equal(filter_positives([-1, -2, -3]), [])
assert_arrays_equal(filter_positives([1, 2, 3]), [1, 2, 3])
}
assert_arrays_equal 같은 도우미를 만들어두면, 배열 비교 테스트가 한 줄로 줄어든다. 실패 시에도 "어느 인덱스에서 다른지"를 정확히 알려준다.
6단계: 테스트 구성 — testsuite_begin과 testsuite_end
테스트 전에 준비(setup)가 필요하거나, 테스트 후에 정리(cleanup)가 필요한 경우가 있다. V는 testsuite_begin과 testsuite_end 함수를 제공한다.
module main
import os
const test_dir = '_test_output'
// 모든 테스트 시작 전에 실행
fn testsuite_begin() {
// 테스트용 디렉토리 생성
os.mkdir_all(test_dir) or {}
println('🔧 테스트 환경 준비 완료')
}
// 모든 테스트 종료 후에 실행
fn testsuite_end() {
// 테스트용 디렉토리 삭제
os.rmdir_all(test_dir) or {}
println('🧹 테스트 환경 정리 완료')
}
fn test_write_and_read_file() {
path := '${test_dir}/test.txt'
// 파일 쓰기
os.write_file(path, '안녕하세요') or {
assert false, '파일 쓰기 실패'
return
}
// 파일 읽기
content := os.read_file(path) or {
assert false, '파일 읽기 실패'
return
}
assert content == '안녕하세요'
}
fn test_file_exists() {
path := '${test_dir}/exists.txt'
assert !os.exists(path), '파일이 아직 없어야 한다'
os.write_file(path, 'test') or {}
assert os.exists(path), '파일이 있어야 한다'
}
실행 순서는 이렇다.
testsuite_begin() ← 1번: 전체 테스트 시작 전
test_write_and_read() ← 2번: 개별 테스트
test_file_exists() ← 3번: 개별 테스트
testsuite_end() ← 4번: 전체 테스트 종료 후
파일 I/O나 데이터베이스를 사용하는 테스트에서 매우 유용하다. 테스트 디렉토리를 만들고, 테스트가 끝나면 깨끗이 정리하는 패턴이다.
7단계: 좋은 테스트의 원칙
코드를 많이 작성하는 것보다, 좋은 테스트를 작성하는 것이 더 중요하다. 실전에서 도움이 되는 원칙을 정리한다.
무엇을 테스트할까?
| 테스트 대상 | 예시 | 우선순위 |
|---|---|---|
| 정상 케이스 | 올바른 입력으로 예상 결과 확인 | ★★★★★ |
| 경계값 | 0, 빈 문자열, 빈 배열, 최대값 | ★★★★★ |
| 에러 케이스 | 파일 없음, 잘못된 JSON, 0 나누기 | ★★★★ |
| 엣지 케이스 | 특수 문자, 매우 긴 입력, 음수 | ★★★ |
테스트 이름 짓기
테스트 이름만 보고 무엇을 검증하는지 알 수 있어야 한다.
// ❌ 나쁜 이름
fn test_1() { ... }
fn test_func() { ... }
// ✅ 좋은 이름
fn test_add_positive_numbers() { ... }
fn test_divide_by_zero_returns_error() { ... }
fn test_empty_input_returns_default() { ... }
AAA 패턴
테스트 코드는 Arrange → Act → Assert 세 단계로 구성하면 가독성이 좋다.
fn test_user_full_name() {
// Arrange — 준비
first := '홍'
last := '길동'
// Act — 실행
result := full_name(first, last)
// Assert — 검증
assert result == '홍 길동'
}
다른 언어 테스트 프레임워크와 비교
| 항목 | V | Go | Python | Rust |
|---|---|---|---|---|
| 프레임워크 | 내장 | 내장 (testing) | pytest (외부) | 내장 (#[test]) |
| 테스트 파일 | *_test.v | *_test.go | test_*.py | 같은 파일 내 |
| 실행 | v test . | go test ./... | pytest | cargo test |
| 검증 | assert | t.Error() | assert | assert_eq! |
| 테이블 테스트 | 배열 반복 | 구조체 배열 | parametrize | 매크로 |
V의 테스트 시스템은 Go와 가장 유사하다. 별도 설치 없이 바로 사용할 수 있고, 파일 이름 규칙으로 테스트를 구분한다.
문법 시리즈 연결
| 활용된 문법 | 배운 편 | 이번 편에서 |
|---|---|---|
assert문 | — | 테스트 검증의 핵심 (새로 소개) |
| 구조체 | 6편 | TestCase, CategoryTest 정의 |
배열, for...in | 4편, 5편 | 테이블 기반 테스트 반복 |
match 패턴 매칭 | 4편 | get_category 함수 |
or {}, !T | 8편 | 에러 발생 테스트 |
json.decode/encode | 실전 2편 | JSON 라운드트립 테스트 |
os 모듈 | 실전 1편 | 파일 I/O 테스트 |
📝 정리
- [x]
assert— 조건이 참인지 검증, 실패 시 파일명·줄 번호·값 표시 - [x]
_test.v— 테스트 전용 파일, 일반 빌드에서 자동 제외 - [x]
test_— 테스트 함수 이름 접두사,v test가 자동 인식 - [x] 테이블 기반 테스트 — 구조체 배열로 여러 케이스를 한 번에 검증
- [x] 에러 테스트 — "실패해야 정상"인 경우
if _ := ... { assert false } - [x] 라운드트립 테스트 — 원본 → 변환 → 역변환 → 원본과 비교
- [x]
testsuite_begin/end— 테스트 전후 setup/cleanup - [x] AAA 패턴 — Arrange(준비) → Act(실행) → Assert(검증)
다음 편 예고
비교 3편: V vs Python — 누가 더 빠른가? 벤치마크 대결
다시 비교 시간! "V가 Python보다 100배 빠르다"는 말이 정말일까? 피보나치 수열, 파일 I/O, JSON 파싱 — 세 가지 벤치마크로 직접 측정해본다. 그리고 "속도가 전부가 아닌 이유"도 함께 이야기한다.