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.vOutput on success:
OK 0.001s test_add_positive_numbers
OK 0.001s test_add_negative_numbers
OK 0.001s test_add_zerosOn failure:
FAIL test_add_positive_numbers
assert add(2, 3) == 6
5 != 6Project Structure
myapp/
├── main.v
├── src/
│ ├── calculator.v
│ └── calculator_test.v
└── tests/
└── integration_test.vV 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 fileImporting 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 versionsCI 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 patternV'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 library —
assertwith 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 expressionorassert expression, "message" - Runner:
v test .for all tests,v test file_test.vfor 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.