V 실전: 테스트와 디버깅 — 버그 없는 V 코드

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의 테스트 파일

assertmain() 안에 넣는 건 임시 방편이다. 실전에서는 별도의 테스트 파일에 테스트를 작성한다. 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_begintestsuite_end

테스트 전에 준비(setup)가 필요하거나, 테스트 후에 정리(cleanup)가 필요한 경우가 있다. V는 testsuite_begintestsuite_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 파싱 — 세 가지 벤치마크로 직접 측정해본다. 그리고 "속도가 전부가 아닌 이유"도 함께 이야기한다.