What This Chapter Teaches

This is the payoff. Everything in the first five chapters — counters, muxes, FSMs, generic register banks, hierarchical bus designs — was building toward this moment. You now have real VHDL designs, and this chapter shows you how to test every one of them without ModelSim, without VHDL testbench boilerplate, without license servers, and without writing a single line of testbench VHDL.

The entire test workflow is one command:

cargo test

That command compiles your VHDL through skalp, loads each design into an in-process simulator, drives inputs, checks outputs, and reports pass/fail with Rust’s standard test runner. If a test fails, you get an error message with the expected value, the actual value, and the signal name. If you need to debug, dump a waveform and open it in the skalp VS Code extension.

By the end of this chapter you will understand:

  • The complete Testbench API: new, set, clock, expect, get_u64, reset, export_waveform
  • How to test the counter from Chapter 1, the timer from Chapter 3, and the I2C FSM from Chapter 3
  • How to write helper functions that make tests readable and reusable
  • How to dump and inspect waveforms when a test fails
  • How skalp’s testing approach compares to traditional VHDL and SystemVerilog testbenches

No prior Rust experience is required beyond what the earlier chapters have shown. The test code is straightforward — set a value, clock some cycles, expect an output.


The Testbench API

skalp provides a Rust crate called skalp_testing that contains the Testbench type. It compiles your VHDL source, loads the design into a cycle-accurate simulator, and gives you methods to interact with it.

Testbench::new — Create a Simulator

use skalp_testing::Testbench;

let mut tb = Testbench::new("src/counter.vhd").await.unwrap();

Takes the VHDL source path. Returns a Result — if the VHDL has errors, you get compile diagnostics. Each test gets its own simulator instance. Tests do not share state and run in parallel.

set — Drive an Input

tb.set("en", 1u8);
tb.set("threshold", 100u32);

Drives an input port to a value. The value takes effect on the next clock edge, mirroring real hardware. Accepts any unsigned integer type (u8, u16, u32, u64), truncated to the port width. Not async — it queues the value immediately.

clock — Advance Time

tb.clock(1).await;    // advance 1 cycle
tb.clock(100).await;  // advance 100 cycles

Runs the simulator for N clock cycles. This is the only way time advances — between clock calls the design is frozen. Tests are deterministic: the same sequence of set and clock calls always produces the same result.

expect — Assert an Output

tb.expect("count", 10u32).await;

Reads a port or internal signal and asserts it equals the expected value. On mismatch:

assertion failed: signal 'count' expected 10, got 7
  in test_counter_counts at tests/counter_test.rs:14

This is a Rust panic — cargo test reports it as a failure with file and line number.

get_u64 — Read a Signal

let value = tb.get_u64("count").await;

Returns the current value as u64. Use this for control flow — polling a done signal, conditional logic based on signal values.

reset — Assert and Release Reset

tb.reset(2).await;

Asserts rst high for N cycles, then deasserts it and clocks one more cycle. If your reset port has a different name, use set and clock manually.

export_waveform — Dump Waveform

tb.export_waveform("build/counter_test.skw.gz").unwrap();

Writes the complete signal history to a .skw.gz file (skalp’s native compressed waveform format). Open with the skalp VS Code extension.

Why Everything Is Async

Every method that touches the simulator requires .await. The simulator engine runs in a separate C++ thread, communicating with the Rust test through async channels. Multiple testbenches run concurrently in the same process. You do not need to understand Rust async — just add .await after every API call except set.


Counter Test Suite

The 8-bit counter from Chapter 1 (src/counter.vhd) has three ports to exercise: rst clears the counter, en enables counting, count is the output. Create tests/counter_test.rs:

use skalp_testing::Testbench;

#[tokio::test]
async fn test_counter_counts() {
    let mut tb = Testbench::new("src/counter.vhd").await.unwrap();
    tb.reset(2).await;
    tb.expect("count", 0u32).await;
    tb.set("en", 1u8);
    for i in 1..=10u32 {
        tb.clock(1).await;
        tb.expect("count", i).await;
    }
}

#[tokio::test]
async fn test_counter_overflow() {
    let mut tb = Testbench::new("src/counter.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("en", 1u8);
    tb.clock(255).await;
    tb.expect("count", 255u32).await;
    tb.clock(1).await;
    tb.expect("count", 0u32).await;
}

#[tokio::test]
async fn test_counter_disable() {
    let mut tb = Testbench::new("src/counter.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("en", 1u8);
    tb.clock(5).await;
    tb.expect("count", 5u32).await;
    tb.set("en", 0u8);
    tb.clock(10).await;
    tb.expect("count", 5u32).await;
}

test_counter_counts — the happy path. After reset, count is 0. Enable counting, advance one cycle at a time, verify each increment.

test_counter_overflow — after 255 increments, one more wraps to 0. Catches off-by-one errors.

test_counter_disable — count to 5, disable for 10 cycles, confirm the count holds. Catches designs where en is ignored.

Run the tests:

cargo test
running 3 tests
test test_counter_counts ... ok
test test_counter_overflow ... ok
test test_counter_disable ... ok

test result: ok. 3 passed; 0 failed; 0 finished in 0.18s

All three run in parallel. To run one test: cargo test test_counter_overflow. To see output: cargo test -- --nocapture.


Timer Test

The timer from Chapter 3 has a prescaler, threshold, and match output. Create tests/timer_test.rs:

use skalp_testing::Testbench;

#[tokio::test]
async fn test_timer_match() {
    let mut tb = Testbench::new("src/timer.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("prescaler", 0u8);  // no division
    tb.set("threshold", 10u32);
    tb.set("enable", 1u8);

    // Count up to threshold (12 cycles: 10 counting + pipeline delays)
    tb.clock(12).await;
    tb.expect("match_out", 1u32).await;
    tb.expect("counter", 0u32).await;  // reset after match
}

// Skipped: test_timer_prescaler requires skalp fix for std_logic() cast
// (https://github.com/girivs82/skalp/issues/22)
// #[tokio::test]
// async fn test_timer_prescaler() {
//     let mut tb = Testbench::new("src/timer.vhd").await.unwrap();
//     tb.reset(2).await;
//     tb.set("prescaler", 1u8);  // divide by 2
//     tb.set("threshold", 5u32);
//     tb.set("enable", 1u8);
//
//     // With prescaler=1, tick fires every 2 cycles, so match takes longer
//     tb.clock(13).await;
//     tb.expect("match_out", 1u32).await;
//     tb.expect("counter", 0u32).await;  // reset after match
// }

#[tokio::test]
async fn test_timer_disabled() {
    let mut tb = Testbench::new("src/timer.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("prescaler", 0u8);
    tb.set("threshold", 10u32);
    tb.set("enable", 0u8);

    tb.clock(100).await;
    tb.expect("counter", 0u32).await;
    tb.expect("match_out", 0u32).await;
}

test_timer_match — with no prescaler, the counter increments every cycle. After 12 cycles (10 counting plus pipeline delays from the prescaler and counter processes), match_out asserts and the counter resets.

test_timer_prescaler — currently skipped pending a skalp compiler fix (#22). With prescaler = 1, the internal tick fires every 2 cycles, halving the counter rate. After 13 cycles the counter reaches the threshold of 5 and match_out asserts.

test_timer_disabled — with enable low, nothing moves even after 100 cycles.


I2C FSM Test

The I2C controller from Chapter 3 has multiple states and handshaking signals. Create tests/i2c_test.rs:

use skalp_testing::Testbench;

#[tokio::test]
async fn test_i2c_idle_state() {
    let mut tb = Testbench::new("src/i2c_fsm.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("sda_in", 1u8);

    tb.expect("busy", 0u32).await;
    tb.expect("done", 0u32).await;
    tb.expect("scl_out", 1u32).await;  // SCL high when idle
}

#[tokio::test]
async fn test_i2c_start_transfer() {
    let mut tb = Testbench::new("src/i2c_fsm.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("sda_in", 1u8);

    tb.expect("busy", 0u32).await;

    // Start a transfer
    tb.set("wr_data", 0xA5u32);
    tb.set("start", 1u8);
    tb.clock(1).await;
    tb.set("start", 0u8);

    tb.expect("busy", 1u32).await;

    // Wait for completion
    for _ in 0..1000 {
        tb.clock(1).await;
        if tb.get_u64("done").await == 1 {
            break;
        }
    }
    tb.expect("done", 1u32).await;
}

#[tokio::test]
async fn test_i2c_returns_to_idle() {
    let mut tb = Testbench::new("src/i2c_fsm.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("sda_in", 1u8);

    // Start and complete a transfer
    tb.set("wr_data", 0x55u32);
    tb.set("start", 1u8);
    tb.clock(1).await;
    tb.set("start", 0u8);

    for _ in 0..1000 {
        tb.clock(1).await;
        if tb.get_u64("done").await == 1 {
            break;
        }
    }

    // After completion, the FSM should return to idle
    tb.clock(2).await;
    tb.expect("busy", 0u32).await;
    tb.expect("done", 0u32).await;
}

test_i2c_idle_state — after reset, the FSM should be idle: not busy, not done, SCL high.

test_i2c_start_transfer — pulse start, confirm busy, poll for done. The get_u64 loop handles protocol-dependent timing.

test_i2c_returns_to_idle — after completing a transfer, the FSM must not get stuck. It should return to idle within a few cycles.


Helper Functions

Patterns repeat as your test suite grows. The I2C polling loop appears twice above. Extract it:

async fn wait_for_done(tb: &mut Testbench, max_cycles: usize) {
    for _ in 0..max_cycles {
        tb.clock(1).await;
        if tb.get_u64("done").await == 1 {
            return;
        }
    }
    panic!("Timeout waiting for done signal after {} cycles", max_cycles);
}

Now the I2C test reads cleanly:

#[tokio::test]
async fn test_i2c_start_transfer_clean() {
    let mut tb = Testbench::new("src/i2c_fsm.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("sda_in", 1u8);

    tb.set("wr_data", 0xA5u32);
    tb.set("start", 1u8);
    tb.clock(1).await;
    tb.set("start", 0u8);

    tb.expect("busy", 1u32).await;
    wait_for_done(&mut tb, 1000).await;
    tb.expect("done", 1u32).await;
}

More useful helpers:

/// Pulse a signal high for one cycle, then low.
async fn pulse(tb: &mut Testbench, signal: &str) {
    tb.set(signal, 1u8);
    tb.clock(1).await;
    tb.set(signal, 0u8);
}

/// Wait until a signal reaches a specific value, or panic after timeout.
async fn wait_for_value(tb: &mut Testbench, signal: &str, value: u64, max_cycles: usize) {
    for _ in 0..max_cycles {
        if tb.get_u64(signal).await == value {
            return;
        }
        tb.clock(1).await;
    }
    panic!("Timeout: '{}' did not reach {} within {} cycles", signal, value, max_cycles);
}

These are regular Rust functions with parameters, return values, and real control flow. This is one of the biggest advantages over VHDL testbenches — you have a real programming language for test infrastructure, not a hardware description language forced into a testing role.


Waveform Debugging

When expect fails, the error message usually tells you enough. When it does not — when you need timing relationships between signals or need to trace an FSM through its states — dump a waveform:

#[tokio::test]
async fn test_i2c_debug() {
    let mut tb = Testbench::new("src/i2c_fsm.vhd").await.unwrap();
    tb.reset(2).await;
    tb.set("sda_in", 1u8);

    tb.set("wr_data", 0xA5u32);
    tb.set("start", 1u8);
    tb.clock(1).await;
    tb.set("start", 0u8);

    tb.clock(200).await;
    tb.export_waveform("build/i2c_debug.skw.gz").unwrap();
}

Open build/i2c_debug.skw.gz in the skalp VS Code extension. You will see every signal — inputs, outputs, internals — at every clock edge.

To avoid dumping on every run, gate it behind an environment variable:

if std::env::var("DUMP_WAVE").is_ok() {
    tb.export_waveform("build/counter_test.skw.gz").unwrap();
}

Normal run: cargo test. With waveforms: DUMP_WAVE=1 cargo test.

.skw.gz is skalp’s native compressed waveform format, viewable in the skalp VS Code extension.


Coming from SystemVerilog/VHDL Testbenches?

Traditional Approachskalp + Rust
Write a VHDL test entity with no portsWrite a Rust function with #[tokio::test]
Instantiate the DUT as a componentTestbench::new("file.vhd")
Generate a clock with wait for 10 ns loopsBuilt in: tb.clock(n)
Drive signals with <= and waittb.set("signal", value)
Check outputs with assert (often missing)tb.expect("signal", value) fails the test
Run in ModelSim, Questa, Vivado Sim, or GHDLcargo test
License required (ModelSim/Questa)Free
UVM for reusable test infrastructureRust functions, structs, traits
CI requires vendor tools on servercargo test in any CI pipeline
Compile time: seconds to minutesCompile time: milliseconds

The most important row is assertions. In a traditional VHDL testbench, it is easy to forget an assert — the simulation runs, produces a waveform, and you visually inspect it. That is manual verification, not testing. With expect, every check is explicit, automated, and fails loudly.

UVM users: skalp is not a UVM replacement for constrained random verification. It covers the 90% case — directed tests for specific behaviors. For most designs under 10,000 lines of VHDL, directed tests with helper functions are sufficient and far more maintainable than a UVM environment.


Test Organization

One File Per Design

Match test files to VHDL source files. Run tests for a single design with cargo test --test counter_test.

src/                    tests/
  counter.vhd             counter_test.rs
  timer.vhd               timer_test.rs
  i2c_fsm.vhd             i2c_test.rs
  bus_controller.vhd       bus_test.rs

One Test Per Behavior

Name tests after the behavior, not the implementation:

// Good: reads like a requirement
#[tokio::test] async fn test_counter_wraps_at_255() { ... }
#[tokio::test] async fn test_counter_holds_when_disabled() { ... }
#[tokio::test] async fn test_timer_fires_at_threshold() { ... }

// Bad: implementation detail
#[tokio::test] async fn test_counter_reg_value() { ... }
#[tokio::test] async fn test_state_machine_state_3() { ... }

Shared Helpers

When multiple test files need the same helpers, put them in tests/common/mod.rs and import with mod common; use common::wait_for_signal;.

Edge Cases to Always Test

CategoryExample
Reset behaviorOutputs are in a known state after reset
Boundary valuesCounter at 0, counter at max, threshold at 0
Enable/disableDesign does nothing when disabled
Overflow/underflowCounter wraps, timer fires at exact threshold
Idle returnFSM returns to idle after completing an operation
Back-to-backStart a new operation immediately after the previous one completes
Invalid inputWhat happens if start is pulsed while busy?

Quick Reference

API MethodSignaturePurpose
Testbench::newnew(path).await.unwrap()Compile VHDL and create simulator
settb.set("port", value)Drive input (takes effect next clock)
clocktb.clock(n).awaitAdvance n clock cycles
expecttb.expect("port", value).awaitAssert signal equals value
get_u64tb.get_u64("port").awaitRead signal as u64
resettb.reset(n).awaitAssert reset for n cycles, then release
export_waveformtb.export_waveform("f.skw.gz").unwrap()Dump signal history to waveform file
TaskCommand
Run all testscargo test
Run one test filecargo test --test counter_test
Run one test by namecargo test test_counter_overflow
Run with output visiblecargo test -- --nocapture
Run with waveform dumpDUMP_WAVE=1 cargo test
Open waveformOpen .skw.gz in skalp VS Code extension
Rust SyntaxMeaning
#[tokio::test]Async test attribute
.awaitWait for async operation
.unwrap()Extract value or panic
1u8, 0xA5u32Typed integer literals
for i in 1..=10u32Inclusive range loop
&mut tbMutable reference (for helpers)

Next: skalp Integration

Your VHDL designs now have a proper test suite that runs with cargo test and catches regressions automatically. In Chapter 7, you will learn how skalp-specific pragmas and features enhance your VHDL:

  • -- skalp: comment pragmas for safety checks, CDC annotations, and signal tracing
  • Formal verification with skalp verify — prove properties, do not just test them
  • Mixed skalp+VHDL designs where some entities use skalp’s native language
  • Integration with skalp’s debug server for VS Code breakpoint debugging

Continue to Chapter 7: skalp Integration.