Skip to main content

The Test & Testbench-Top

The environment from the previous page is a complete, self-contained, reusable unit — but it has no opinion about when to run or what stimulus to run with. Both of those are the test's job. And the environment still isn't connected to an actual DUT — that's the job of one last piece that isn't UVM at all: a plain Verilog module.

The test: build the environment, start a sequence, own the objection​

class fifo_base_test extends uvm_test;
`uvm_component_utils(fifo_base_test)
fifo_env env;

function new(string name, uvm_component parent);
super.new(name, parent);
endfunction

function void build_phase(uvm_phase phase);
super.build_phase(phase);
env = fifo_env::type_id::create("env", this);
endfunction

task run_phase(uvm_phase phase);
fifo_write_read_seq seq = fifo_write_read_seq::type_id::create("seq");
phase.raise_objection(this);
seq.start(env.agent.sequencer);
phase.drop_objection(this);
endtask
endclass

A test sits at the very top of the component tree — nothing else builds it, since it's what run_test() (below) constructs directly. Its build_phase does exactly one thing: construct the environment. Its run_phase does exactly three: raise an objection (so the phase doesn't end before anything happens — see Phases & Objections for the full mechanics), start a sequence on the environment's sequencer, and drop the objection once that sequence completes.

Reusing the environment across many tests​

The real payoff of keeping build_phase this minimal shows up the moment a second test is needed:

class fifo_write_only_test extends fifo_base_test;
`uvm_component_utils(fifo_write_only_test)

function new(string name, uvm_component parent);
super.new(name, parent);
endfunction

// build_phase is inherited unchanged — same environment, no re-wiring
task run_phase(uvm_phase phase);
fifo_write_only_seq seq = fifo_write_only_seq::type_id::create("seq");
phase.raise_objection(this);
seq.start(env.agent.sequencer);
phase.drop_objection(this);
endtask
endclass

fifo_write_only_test gets the exact same environment for free, by inheriting build_phase rather than copy-pasting it — the only thing that changes between tests is which sequence run_phase starts. Copy-pasting build_phase into every new test class instead would work in the short term, but it means every future change to how the environment is built (a new coverage collector, a config knob, an extra agent) has to be repeated in every single test file rather than made once. Extending fifo_base_test and overriding only run_phase is what keeps the environment truly reusable, exactly as The Agent & Environment framed the environment's whole reason for existing.

Testbench-top: the handoff from plain Verilog to UVM​

module tb_top;
bit clk, rst_n;
always #5 clk = ~clk;

fifo_if fifo_if_inst (.clk(clk), .rst_n(rst_n));

sync_fifo dut (
.clk (clk),
.rst_n (rst_n),
.wr_en (fifo_if_inst.wr_en),
.wr_data (fifo_if_inst.wr_data),
.full (fifo_if_inst.full),
.rd_en (fifo_if_inst.rd_en),
.rd_data (fifo_if_inst.rd_data),
.empty (fifo_if_inst.empty)
);

initial begin
rst_n = 0;
#20 rst_n = 1;
uvm_config_db#(virtual fifo_if)::set(null, "*", "vif", fifo_if_inst);
run_test("fifo_base_test");
end
endmodule

Nothing above run_test() is UVM at all — tb_top is an ordinary module doing three ordinary things: generating a clock, instantiating the fifo_if interface from Interface & Transaction, and instantiating the DUT itself (sync_fifo), wired to that same interface's signals.

  • uvm_config_db#(virtual fifo_if)::set(null, "*", "vif", fifo_if_inst) is the one line where plain Verilog reaches into the UVM world — publishing the interface handle every build_phase in this section's driver and monitor retrieved with get(). It has to run before run_test(), since build_phase (which calls get()) happens as part of what run_test() kicks off.
  • run_test("fifo_base_test") is the handoff point itself: everything before this line is plain-Verilog simulation setup; this call is what actually constructs fifo_base_test, and through it, the entire tree built up over this whole section — env, agent, driver, monitor, sequencer, scoreboard — then runs it through every phase from Phases & Objections in order. Whichever test class ends up resolved (fifo_base_test here, or a different one via +UVM_TESTNAME), run_test() always constructs it under one fixed instance name, "uvm_test_top", parented under the uvm_root singleton from The Singleton Pattern — not a name derived from the test's class. This is why hierarchical paths throughout this curriculum (an instance override's target path, a uvm_config_db scope) always start with uvm_test_top, regardless of which specific test class was actually run.
Picking the test without recompiling: +UVM_TESTNAME

run_test("fifo_base_test") hardcodes the test class as a string literal, so switching tests means editing tb_top and recompiling. UVM supports a second way that avoids that: call run_test() with no argument in tb_top, and pass the test class name on the simulator command line instead, e.g. ./simv +UVM_TESTNAME=fifo_write_only_test. run_test() reads that plusarg at elaboration time and factory-constructs whichever class name it names — the same fifo_base_test/fifo_write_only_test pair from this page works either way, no source change required. This is why real regressions compile the testbench once and re-run it many times with a different +UVM_TESTNAME per test, rather than recompiling per test. If both a string argument and +UVM_TESTNAME are given, the command-line plusarg wins; if more than one +UVM_TESTNAME is passed on the same invocation, the first one is used and the rest are ignored with a warning.

What's next​

Every piece from this section — interface, transaction, driver, monitor, sequencer, sequence, scoreboard, agent, environment, test, and testbench-top — now exists. The Example Walkthrough shows them all together, back to back, as the single complete file this section built one piece at a time.