16.7 Testbench Basics: Verifying a Design in Simulation

Write a basic Verilog testbench that instantiates a design under test, applies timed stimulus, and runs to completion, and explain the five elements a testbench needs to actually verify a design.

Writing an RTL module is only half of digital design — before that module is trusted (let alone sent to fabrication or loaded onto an FPGA for real use), it must be verified in simulation. A testbench is itself a piece of Verilog code, but unlike RTL, it is never meant to be synthesized into hardware: its entire job is to instantiate the design under test (DUT), drive controlled input stimulus into it over simulated time, and let an engineer observe or check whether the outputs behave as expected. This lesson covers the minimal testbench structure needed to simulate the and_gate module from 16.1, and the five essential elements — DUT, stimulus, expected result, assertions, and waveform inspection — that every real testbench builds on.

The Five Elements of a Testbench

The module names five things every testbench needs to provide, and it's worth understanding each one's role individually before looking at the code. The DUT (Design Under Test) is the actual RTL module being verified — the testbench instantiates it exactly the way any parent module would instantiate a child module, connecting testbench-driven signals to its ports. Stimulus is the sequence of input values, and the timing between them, that the testbench applies to the DUT's inputs to exercise its behavior.

Expected result is whatever the engineer compares the DUT's actual output against — in a minimal testbench this might just be a human reading printed values or a waveform; in a more rigorous one it's a reference value computed independently and checked automatically. Assertions are explicit statements in the testbench (or design) that automatically flag a failure the instant an expected condition is violated, rather than relying on a human to notice. Waveform inspection is the practice of dumping every signal's value over simulated time into a file that a waveform viewer can display, letting an engineer visually trace exactly what happened, and when, for debugging.

• DUT — the module actually being tested, instantiated inside the testbench. • Stimulus — the timed sequence of input values applied to the DUT. • Expected result — what the DUT's output should be, to compare against what it actually is. • Assertions — automatic pass/fail checks embedded in the simulation. • Waveform inspection — a time-domain view of every signal, for visual debugging.

Anatomy of the Testbench Module

A testbench is still written as a module, but it is a fundamentally different kind of module from an RTL design: it typically has no ports at all (module tb; with nothing in parentheses), because nothing outside the simulation ever instantiates a testbench — it sits at the very top of the simulation hierarchy. Signals connecting to the DUT are declared as logic (or the older reg) rather than wire, specifically because the testbench needs to procedurally drive them with timed stimulus, which — exactly as in 16.4 and 16.5 — requires a variable type, not a plain wire.

The DUT is instantiated using named port connections (.a(a), .b(b), .y(y) in the module's own example), which explicitly pairs each of the DUT's port names with a testbench signal of the same or a different name — named connection is strongly preferred over positional connection in real code because it remains correct even if the DUT's port order changes later, and it makes the wiring self-documenting.

Stimulus with initial and Time Delays

The initial begin ... end block is a simulation-only construct (covered further in 16.8) that runs exactly once, starting at the beginning of simulation time, executing its statements in order — this is precisely the ordinary, sequential, 'run these steps one after another' behavior that a testbench needs and that RTL design (16.4, 16.5, 16.6) deliberately avoids relying on. Inside it, #10 is a delay statement: it tells the simulator to advance 10 simulation time units before executing the next statement, letting the testbench pace out its stimulus deliberately (for example, giving the DUT time to settle after each input change, or aligning stimulus changes to clock edges in a clocked design).

The module's own testbench example walks the and_gate DUT through its meaningful input combinations over time: it starts both inputs at 0, waits 10 time units and raises a to 1, waits another 10 units and raises b to 1 as well, then waits a final 10 units before calling $finish. $finish is a system task that ends the simulation entirely — without it (or an equivalent), a simulator would simply run out of scheduled events and stop, but explicitly calling $finish is the conventional, clear way to mark 'this test is complete.'

Minimal Testbench Structure

A testbench module typically has no port list at all — it sits at the top of the simulation hierarchy and is never instantiated by anything else. .port(signal) named connection pairs each DUT port explicitly with a testbench signal, and remains correct even if the DUT's port order changes. #10 advances simulated time by 10 units before the next statement runs; $finish ends the simulation.

module tb;

logic a, b;      // stimulus signals driven by the testbench
logic y;         // observed output from the DUT

// instantiate the DUT with named port connections
and_gate dut (
    .a(a),
    .b(b),
    .y(y)
);

initial begin
    a = 0; b = 0;
    #10 a = 1;
    #10 b = 1;
    #10 $finish;
end

endmodule

Code Examples

This is the module's own worked testbench. It declares a, b, y as logic (they must be procedurally drivable), instantiates and_gate as dut with each port explicitly named, and then runs one initial block that walks through three stimulus combinations spaced 10 time units apart: (a=0,b=0), then (a=1,b=0), then (a=1,b=1) — covering every input combination that matters for a 2-input AND gate except the redundant repeat of (0,0). After the final delay, $finish ends the simulation. Expected result: y should read 0 for the first 10 time units, 0 for the next 10 (a=1,b=0), then 1 for the final 10 (a=1,b=1) — matching the AND truth table.

This extends the minimal testbench with an explicit expected-result check: after the same stimulus sequence, it compares the DUT's actual output y against the independently computed expected value a & b, and prints a clear PASS or FAIL message using the $display system task, which formats and prints text during simulation (roughly analogous to a print statement, but for simulation time rather than program execution order). This turns a testbench a human would have to watch a waveform to judge into one that reports its own result. !== is a case-inequality comparison that also catches unknown (X) or high-impedance (Z) values, which is a safer comparison to use in a testbench check than plain !=.

if (y !== (a & b))

module tb;

logic a, b;
logic y;

and_gate dut (
    .a(a),
    .b(b),
    .y(y)
);

initial begin
    a = 0; b = 0;
    #10 a = 1;
    #10 b = 1;
    #10 $finish;
end

endmodule
module tb_checked;

logic a, b;
logic y;

and_gate dut (
    .a(a),
    .b(b),
    .y(y)
);

initial begin
    a = 0; b = 0;
    #10 a = 1;
    #10 b = 1;
    #10;
    if (y !== (a & b))
        $display("FAIL: y=%b expected=%b at time %0t", y, a & b, $time);
    else
        $display("PASS: y matches a & b at time %0t", $time);
    $finish;
end

endmodule

Testbench driving a DUT over simulated time

The testbench (initial block) drives a and b directly; y is the DUT's own output, observed but never driven by the testbench.

time:    0         10        20        30
         |---------|---------|---------|
a        0000000000111111111111111111111
b        0000000000000000000011111111111
y        0000000000000000000011111111111
                                          ^
                                       $finish

Important Rules

A testbench module typically declares no ports — it is the top of the simulation hierarchy, never instantiated by anything else.

Signals the testbench drives into the DUT must be logic/reg, not wire, since they're assigned procedurally inside initial.

initial begin...end runs its statements exactly once, in order, starting at simulation time 0 — this sequential-execution style is appropriate for a testbench, not for RTL.

#<N> delays advance simulated time by N units before the next statement executes, letting stimulus be paced deliberately.

$finish ends the simulation explicitly; a well-formed testbench should always reach it (directly or via a timeout) so the simulation terminates cleanly.

Why Nothing Gets Fabricated Without a Testbench

In professional practice, no RTL module is considered trustworthy — let alone sent toward FPGA deployment or ASIC fabrication, where a bug can cost months and enormous sums to fix after the fact — until it has a testbench (usually a far more elaborate one than this lesson's minimal example, often including randomized stimulus, coverage tracking, and dozens or hundreds of assertions) that has been run and reviewed. Verification, built on exactly the DUT/stimulus/expected-result/assertion/waveform structure introduced here, is frequently a larger engineering effort than writing the RTL itself.

and_gate dut (a, b, y); // positional connection, DUT port order assumed and_gate dut (.a(a),.b(b),.y(y));

Positional port connection silently breaks if the DUT's port order is ever changed (for example, if and_gate's ports are reordered to (output y, input a, input b)), because the testbench has no way to notice the mismatch — it just wires the wrong signals to the wrong ports. Named connection (.portname(signal)) stays correct regardless of the DUT's internal port order and documents the intent directly in the instantiation.

What are the five elements this lesson identifies as necessary parts of a testbench? A. DUT, stimulus, expected result, assertions, waveform inspection B. Module, port, wire, reg, always block C. Clock, reset, data, enable, output D. Synthesis, place-and-route, timing analysis, fabrication, packaging

A. DUT, stimulus, expected result, assertions, waveform inspection — These are the five elements named directly for testbench coverage: the DUT is what's being tested; stimulus drives its inputs; expected result is what to compare against; assertions catch failures automatically; and waveform inspection lets an engineer visually trace signal behavior over time for debugging.

[interview] Why is a testbench module written with no port list, unlike the RTL modules it tests? A. Because a testbench sits at the very top of the simulation hierarchy and is never itself instantiated by anything else, so it has no external interface to declare B. Because Verilog does not allow simulation-only modules to have ports C. Because a testbench with ports would be automatically synthesized into hardware by mistake D. Because initial blocks cannot be used inside a module that has ports

A. Because a testbench sits at the very top of the simulation hierarchy and is never itself instantiated by anything else, so it has no external interface to declare — Every RTL module needs ports because it's meant to be instantiated inside a larger design (or, ultimately, connected to real chip pins). A testbench is the opposite: it is the top-level entity of a simulation, instantiating the DUT itself rather than being instantiated by anything, so it has nothing external to connect to and needs no port list.

In the testbench's initial block, what does the statement #10 a = 1; do? A. It advances simulated time by 10 time units, then assigns a the value 1 B. It assigns a the value 1 for exactly 10 time units, then reverts it C. It runs the assignment 10 times in a row D. It sets a to the binary value 10 (decimal 2), which is invalid for a 1-bit signal

A. It advances simulated time by 10 time units, then assigns a the value 1 — #10 is a delay control: the simulator pauses this initial block's execution for 10 simulated time units, then proceeds to the next statement — here, setting a = 1;. This is how a testbench paces out its stimulus over simulated time rather than applying every change instantaneously at time 0.

Extend the tb testbench so that after the final stimulus step (a=1, b=1), it also tests the case a=0, b=1, then finishes. Use $display to print the value of y after every stimulus change.

module tb_extended; logic a, b; logic y; and_gate dut ( .a(a), .b(b), .y(y) ); initial begin a = 0; b = 0; #10 a = 1; #10 $display("a=1 b=0 -> y=%b at t=%0t", y, $time); #0 b = 1; #10 $display("a=1 b=1 -> y=%b at t=%0t", y, $time); #0 a = 0; #10 $display("a=0 b=1 -> y=%b at t=%0t", y, $time); $finish; end endmodule

Add one more #10-delayed assignment (setting a back to 0 while b stays 1), and place a $display call after each delay to report y and $time.