V Language Testing Guide: vtest, Built-in assert, and CI Integration

V Language Testing Guide: vtest, Built-in assert, and CI Integration

V (vlang) has a built-in test runner that requires no external frameworks. Test files end with _test.v and contain functions starting with test_. V compiles and runs them with v test file_test.v or v test . to run all tests in a directory. The primary assertion mechanism is V's assert statement.

Key Takeaways

Test files end with _test.v. V's test runner discovers files by this convention. Non-test files are ignored.

Test functions start with test_. Any function matching test_* in a _test.v file is a test. No test framework to import.

assert is the primary assertion. V's built-in assert expression fails the test if the expression is false, printing the expression and its value. No assertion library needed.

v test . runs all tests in the current directory recursively. Use this for CI. Use v test specific_test.v for development iteration.

V's assert prints the failing expression automatically. assert x == 5 on failure prints "x == 5 failed (x = 3)" — readable without an assertion library.

V's Testing Philosophy

V (vlang) prioritizes simplicity. Its testing approach reflects this: no imports, no test framework objects, no annotation system. A file with _test.v suffix contains functions starting with test_, and v test runs them.

This is similar to Go's _test.go convention but even simpler — V uses a plain assert statement rather than a testing package.

Writing Your First Test

// math_test.v
fn add(a int, b int) int {
    return a + b
}

fn test_add_positive_numbers() {
    assert add(2, 3) == 5
}

fn test_add_negative_numbers() {
    assert add(-3, 2) == -1
}

fn test_add_zeros() {
    assert add(0, 0) == 0
}

Run:

v test math_test.v

Output on success:

OK  0.001s    test_add_positive_numbers
OK  0.001s    test_add_negative_numbers
OK  0.001s    test_add_zeros

On failure:

FAIL  test_add_positive_numbers
assert add(2, 3) == 6
       5         != 6

Project Structure

myapp/
├── main.v
├── src/
│   ├── calculator.v
│   └── calculator_test.v
└── tests/
    └── integration_test.v

V finds all *_test.v files recursively:

v test .          <span class="hljs-comment"># All tests in current directory
v <span class="hljs-built_in">test src/       <span class="hljs-comment"># All tests in src/
v <span class="hljs-built_in">test src/calculator_test.v  <span class="hljs-comment"># Specific file

Importing Modules in Tests

When testing a module, import it normally:

// src/calculator_test.v
module main  // or the appropriate module

import src.calculator

fn test_calculator_add() {
    mut calc := calculator.Calculator{}
    assert calc.add(10, 5) == 15
}

fn test_calculator_divide() {
    mut calc := calculator.Calculator{}
    assert calc.divide(10, 2) == 5.0
}

fn test_calculator_divide_by_zero_returns_error() {
    mut calc := calculator.Calculator{}
    result := calc.divide_safe(10, 0)
    assert result.err != none
}

Advanced assert Usage

V's assert supports adding a message:

fn test_with_message() {
    x := compute_value()
    assert x > 0, "compute_value() should return a positive number, got ${x}"
}

For multiple assertions in one test:

fn test_order_processing() {
    order := Order{
        id: "ord-1"
        customer_id: "c1"
        total: 99.99
    }
    
    processed := process_order(order)
    
    assert processed.status == "completed", "Status should be completed"
    assert processed.invoice_id.len > 0, "Invoice ID should be set"
    assert processed.total == 99.99, "Total should not change"
    assert !processed.error, "Should not have errors"
}

Testing Structs and Methods

// src/user_test.v
struct User {
    email string
    name  string
mut:
    active bool
}

fn (mut u User) deactivate() {
    u.active = false
}

fn (u User) is_valid() bool {
    return u.email.contains("@") && u.name.len > 0
}

fn test_user_is_valid_with_correct_data() {
    user := User{email: "alice@example.com", name: "Alice", active: true}
    assert user.is_valid()
}

fn test_user_is_invalid_without_at_sign() {
    user := User{email: "not-an-email", name: "Alice"}
    assert !user.is_valid()
}

fn test_user_is_invalid_with_empty_name() {
    user := User{email: "alice@example.com", name: ""}
    assert !user.is_valid()
}

fn test_user_deactivation() {
    mut user := User{email: "bob@example.com", name: "Bob", active: true}
    assert user.active
    user.deactivate()
    assert !user.active
}

Testing Error Handling

V uses result types (! prefix or ?) for error handling. Test both success and failure paths:

// src/parser.v
fn parse_int(s string) !int {
    if s.len == 0 {
        return error("empty string")
    }
    return s.int()
}

// src/parser_test.v
fn test_parse_valid_integer() {
    result := parse_int("42") or { panic(err.msg()) }
    assert result == 42
}

fn test_parse_negative_integer() {
    result := parse_int("-7") or { panic(err.msg()) }
    assert result == -7
}

fn test_parse_empty_string_returns_error() {
    result := parse_int("")
    assert result == error("empty string")
}

fn test_parse_invalid_string_returns_zero() {
    // V's string.int() returns 0 for non-numeric strings
    result := parse_int("abc") or { panic(err.msg()) }
    assert result == 0
}

Setup and Teardown

V's test runner doesn't provide before_each/after_each hooks. Handle setup in each test function or use a helper:

// test/inventory_test.v
struct Inventory {
mut:
    items map[string]int
}

fn new_test_inventory() Inventory {
    return Inventory{
        items: {
            "sword": 3
            "shield": 1
            "potion": 10
        }
    }
}

fn test_inventory_has_initial_items() {
    inv := new_test_inventory()
    assert inv.items["sword"] == 3
    assert inv.items["potion"] == 10
}

fn test_inventory_remove_item() {
    mut inv := new_test_inventory()
    inv.items["sword"] -= 1
    assert inv.items["sword"] == 2
}

fn test_inventory_empty_after_removing_all() {
    mut inv := new_test_inventory()
    inv.items.delete("shield")
    assert "shield" !in inv.items
}

Parameterized Tests via Loops

V lacks a parametrize decorator, but loops work well:

fn test_factorial() {
    cases := [
        (0, 1),
        (1, 1),
        (2, 2),
        (3, 6),
        (4, 24),
        (5, 120),
    ]!

    for case in cases {
        n, expected := case
        result := factorial(n)
        assert result == expected, "factorial(${n}) should be ${expected}, got ${result}"
    }
}

Benchmarks

V supports benchmark functions alongside tests:

// bench_test.v
fn test_add_performance() {
    // Regular test — assert-based
    for i in 0 .. 1000 {
        assert add(i, i) == i * 2
    }
}

// V also supports timing via -bench flag in some versions

CI Integration

# .github/workflows/test.yml
name: V Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup V
        run: |
          git clone --depth=1 https://github.com/vlang/v.git /tmp/vlang
          cd /tmp/vlang && make
          echo "/tmp/vlang" >> $GITHUB_PATH

      - name: Run tests
        run: v test .

      - name: Run tests with verbose output
        run: v test -v .

V Test Flags

v test .              <span class="hljs-comment"># Run all tests
v <span class="hljs-built_in">test . -v           <span class="hljs-comment"># Verbose: show each test name
v <span class="hljs-built_in">test . -stats       <span class="hljs-comment"># Show timing stats
v <span class="hljs-built_in">test file_test.v    <span class="hljs-comment"># Single file
v <span class="hljs-built_in">test -run test_add  <span class="hljs-comment"># Run tests matching pattern

V's Testing Limitations

V's test tooling is simpler than mature languages. Current limitations:

  • No setup/teardown hooks — write helper functions instead
  • No parameterized tests — use loops with descriptive assert messages
  • No mock library — use dependency injection with interfaces
  • Limited assertion libraryassert with messages is the only built-in option
  • No snapshot testing — write your own file comparison

These limitations reflect V's young ecosystem (as of 2026). The community is growing and third-party libraries are emerging.

Summary

V's built-in testing is minimal but functional:

  • Test files: *_test.v
  • Test functions: test_*
  • Assertion: assert expression or assert expression, "message"
  • Runner: v test . for all tests, v test file_test.v for specific files
  • No framework imports needed — V's test runner handles discovery and execution

V rewards the testing-as-first-class-feature philosophy: tests live next to source, assertions are clear, CI integration is a single command. For a language focused on simplicity, its testing system is a natural fit.

Read more