From "it printed some numbers" to "it checked itself"
Every testbench in lessons 2 through 9 printed values with $display and left a human to read them and decide whether they looked right. That does not scale past a handful of test cases, and it is not what a real testbench does. A self-checking testbench compares every result against a value it already knows is correct and reports PASS or FAIL itself โ the entire reason to write a testbench at all is to replace "a human reads the output" with "the test tells you the answer."
A task: bundling one test case into one call
A task is a named, reusable block of procedural code โ the nearest thing Verilog has to a function with side effects, since a task can consume simulation time (it can contain delays, like #1) and can update signals outside itself, neither of which a function is allowed to do. Bundling "apply these inputs, wait for them to settle, check the outputs" into one task turns a test sequence from repeated boilerplate into a short, readable list of cases.
module tb_self_check;
reg a, b;
wire sum, carry;
integer errors = 0;
half_adder dut (.a(a), .b(b), .sum(sum), .carry(carry));
task check;
input ta, tb, exp_sum, exp_carry;
begin
a = ta; b = tb;
#1;
if (sum !== exp_sum || carry !== exp_carry) begin
$display("FAIL: a=%b b=%b -> sum=%b carry=%b (expected sum=%b carry=%b)",
a, b, sum, carry, exp_sum, exp_carry);
errors = errors + 1;
end else
$display("PASS: a=%b b=%b -> sum=%b carry=%b", a, b, sum, carry);
end
endtask
initial begin
check(1'b0, 1'b0, 1'b0, 1'b0);
check(1'b0, 1'b1, 1'b1, 1'b0);
check(1'b1, 1'b0, 1'b1, 1'b0);
check(1'b1, 1'b1, 1'b0, 1'b1);
if (errors == 0) $display("ALL TESTS PASSED");
else $display("%0d TEST(S) FAILED", errors);
$finish;
end
endmodule
PASS: a=0 b=0 -> sum=0 carry=0
PASS: a=0 b=1 -> sum=1 carry=0
PASS: a=1 b=0 -> sum=1 carry=0
PASS: a=1 b=1 -> sum=0 carry=1
ALL TESTS PASSED
Two small details are doing real work here. The comparison uses !==, lesson 3's case-inequality operator, specifically so that an unexpected x on sum or carry registers as a definite failure rather than the silent, ambiguous x that != would produce โ exactly the reason lesson 3 introduced ===/!== in the first place. And errors accumulates across every call to check, so the final PASS/FAIL summary reflects the whole test run, not just the last case.
Proving the checker itself works
A self-checking testbench that only ever prints PASS is not yet trustworthy โ it might be comparing against the wrong thing, or not comparing at all. The only way to know the checker works is to feed it a case it should fail, on purpose, and confirm it actually says so.
FAIL: a=1 b=1 -> sum=0 carry=1 (expected sum=1 carry=1)
1 TEST(S) FAILED
This run calls check(1, 1, 1, 1) โ a deliberately wrong expected sum (the real sum of 1 and 1 is 0, not 1). The testbench catches it immediately: the actual simulated outputs (sum=0 carry=1) do not match what was asserted, the mismatch is reported with both the actual and expected values side by side, and the error counter increments. This is worth doing once for any new testbench, specifically because a checker that always reports PASS regardless of input is a worse testbench than no checker at all โ it looks finished while verifying nothing.
Dumping a waveform
$dumpfile names the output file, and $dumpvars selects what to record into it โ $dumpvars(0, tb_self_check) records every signal in and below the named scope, recursively, which is the usual choice for a small testbench.
initial begin
$dumpfile("tb_self_check.vcd");
$dumpvars(0, tb_self_check);
// ... the rest of the test sequence
end
Running the testbench produces a VCD (Value Change Dump) file alongside the usual console output โ a plain-text, standardized log of every signal's value and exactly when it changed, openable in a waveform viewer such as GTKWave. A $display trail tells you what the testbench concluded; a waveform lets you see what every signal was actually doing at every moment, which is indispensable the moment a test fails and the question becomes "why," not just "whether."
Reading a waveform, in brief
A waveform viewer draws time along the horizontal axis and each selected signal as its own row, with its value shown as a changing level for a 1-bit signal or as a changing hex/decimal number for a vector. The habit worth building early is to add the clock and reset to every waveform view first, since almost every other signal's meaning is relative to where it sits between clock edges โ a signal that looks "wrong" in isolation is often simply being read at the wrong point in the cycle. Lesson 8's pipeline example is a good one to actually open in a viewer once built: watching q1, q2, and q3 advance one clock edge apart from each other, visually, makes the non-blocking-assignment timing concrete in a way the console trail alone does not.
Common mistakes
Comparing with == instead of !==/=== inside a checker is the first mistake, and it is a quiet one โ an unexpected x on a checked signal makes the comparison itself evaluate to x, which an if treats as false, so the checker silently falls into the "equal" branch or the "not equal" branch depending on exactly how the condition was written, neither of which is the loud failure an unexpected unknown deserves. The second is forgetting the settling delay (#1 above) between driving new inputs and checking outputs โ checking combinational outputs in the same simulation instant they were driven can read stale values, depending on event-scheduling order, which is the same class of problem lesson 8 covers for sequential logic. The third is never actually testing the checker's failure path โ writing dozens of test cases that all happen to pass proves the design and the checker agree, not that the checker would catch a real bug if one were introduced.
Interview questions
What makes a testbench "self-checking," and why does it matter? It compares every simulated result against an expected value it already knows is correct and reports PASS or FAIL on its own, rather than printing values for a human to read and judge. This is what makes a test suite usable at scale โ a human cannot reliably eyeball thousands of test cases, a self-checking testbench can run them unattended and report only what actually went wrong.
Why use !== rather than != inside a testbench's comparison? != evaluates to x (not a clean true or false) if either side has an unknown bit, which an if then treats ambiguously. !== compares bits literally, including x and z, and always returns a definite answer โ so a genuinely unexpected unknown on the output is guaranteed to register as a real, visible failure.
What is a VCD file, and what is it used for? A standardized, plain-text record of every dumped signal's value and the simulation time at which it changed, produced by $dumpfile/$dumpvars. It is opened in a waveform viewer to visually inspect exactly what every signal did over time โ most useful once a self-checking testbench reports a failure and the next question is why.
Key takeaways
- A
taskbundles a repeated test step (apply inputs, wait, check outputs) into one reusable call, turning a test sequence into a short list of cases instead of repeated boilerplate. - Use
!==/===, not!=/==, inside a checker โ this is what guarantees an unexpectedxregisters as a real failure rather than an ambiguous one. - A checker is not trustworthy until it has been shown to actually fail on a deliberately wrong expected value โ confirmed here with a real, intentionally broken test case.
$dumpfile/$dumpvarsproduce a VCD waveform file, openable in a viewer, for inspecting exactly what every signal did over time โ most useful once a self-checking testbench has already told you something failed.