Getting Started with cocotb
This guide shows how to create a basic test for flow/storage hardware components (such as pipes, FIFOs, etc.) using the cocotb framework. Cocotb allows you to write testbenches in Python, which are then used to verify VHDL/Verilog designs.
The examples in this guide use the MVB FIFOX component test located at comp/mvb_tools/storage/fifox/cocotb/cocotb_test.py as a reference.
Quick Start
For beginners, the easiest way to get started is:
Create a ``cocotb`` folder in the directory of the component you want to test
Copy template files from an existing test (e.g.,
comp/mvb_tools/storage/fifox/cocotb/)Modify the files for your component: - Update the
TOPLEVELin the Makefile to match your component name - Adjust signal names and bus parameters in the testbench - Update the model function to compute expected outputs for your componentRun the test using the Makefile
Note
For automatic generation of a test template, use the generate_test_template script in ndk-fpga/build/scripts/cocotb. This creates a basic test structure that you can customize.
Test Structure
A cocotb test consists of two main parts:
1. Testbench Class - Reusable setup code that encapsulates all test infrastructure:
Drivers - Objects that write stimulus data to the DUT (Device Under Test) input interfaces
Monitors - Objects that read output data from the DUT and convert it to transactions
Scoreboard - Compares actual outputs (from monitors) against expected outputs
Expected outputs - List of transactions that the DUT should produce
Optional objects - Probes for throughput measurement, bit drivers for backpressure testing
Reset sequence - Hardware reset initialization
The testbench class is typically reusable across multiple tests and can be copied/adapted for similar components.
2. Test Function - The actual test with:
@cocotb.test()decorator (required) - Marks the function as a cocotb test
asyncfunction definition (required) - Enables coroutine-based simulationTest logic - Stimulus generation, DUT interaction, and verification
Example test file structure:
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright (C) 2025 CESNET z. s. p. o.
3# Author(s): Ondřej Schwarz <ondrejschwarz@cesnet.cz>
4# Daniel Kondys <kondys@cesnet.cz>
5
6
7# importing required modules
8import itertools
9
10import cocotb
11from cocotb.clock import Clock
12from cocotb.triggers import RisingEdge, ClockCycles
13from cocotbext.ofm.mvb.drivers import MVBDriver
14from cocotbext.ofm.mvb.monitors import MVBMonitor
15from cocotbext.ofm.ver.generators import random_integers
16from cocotb_bus.drivers import BitDriver
17from cocotb_bus.scoreboard import Scoreboard
18from cocotbext.ofm.utils.throughput_probe import ThroughputProbe, ThroughputProbeMvbInterface
19from cocotbext.ofm.base.generators import ItemRateLimiter
20from cocotbext.ofm.mvb.transaction import MvbTrClassic
21
22
23# definition of the class encapsulating components of the test
24class testbench():
25 # dut = device tree of the tested component
26 def __init__(self, dut, debug=False):
27 self.dut = dut
28
29 # setting up the input driver and connecting it to signals beginning with "RX"
30 self.stream_in = MVBDriver(dut, "RX", dut.CLK)
31
32 # setting up the output monitor and connecting it to signals beginning with "TX"
33 self.stream_out = MVBMonitor(dut, "TX", dut.CLK, tr_type=MvbTrClassic)
34
35 # setting up driver of the DST_RDY so it randomly fluctuates between 0 and 1
36 self.backpressure = BitDriver(dut.TX_DST_RDY, dut.CLK)
37
38 # setting up the probe measuring throughput
39 self.throughput_probe = ThroughputProbe(ThroughputProbeMvbInterface(self.stream_out), throughput_units="items")
40 self.throughput_probe.set_log_period(10)
41 self.throughput_probe.add_log_interval(0, None)
42
43 # counter of sent transactions
44 self.pkts_sent = 0
45
46 # list of the transactions that are expected to be received
47 self.expected_output = []
48
49 # setting up a scoreboard which compares received transactions with the expected transactions
50 self.scoreboard = Scoreboard(dut)
51
52 # linking a monitor with its expected output
53 self.scoreboard.add_interface(self.stream_out, self.expected_output)
54
55 # setting up the logging level
56 if debug:
57 self.stream_in.log.setLevel(cocotb.logging.DEBUG)
58 self.stream_out.log.setLevel(cocotb.logging.DEBUG)
59
60 # method for adding transactions to the expected output
61 def model(self, transaction):
62 """Model the DUT based on the input transaction"""
63 self.expected_output.append(transaction)
64 self.pkts_sent += 1
65
66 # method preforming a hardware reset
67 async def reset(self):
68 self.dut.RESET.value = 1
69 await ClockCycles(self.dut.CLK, 10)
70 self.dut.RESET.value = 0
71 await RisingEdge(self.dut.CLK)
72
73
74# defining a test - functions with "@cocotb.test()" decorator will be automatically found and run
75@cocotb.test()
76async def run_test(dut, pkt_count=10000):
77 # start a clock generator
78 cocotb.start_soon(Clock(dut.CLK, 5, units="ns").start())
79
80 # initialization of the test bench
81 tb = testbench(dut, debug=False)
82
83 # change MVB driver's IdleGenerator to ItemRateLimiter
84 # note: the RateLimiter's rate is affected by backpressure (DST_RDY).
85 # Even though it takes into account cycles with DST_RDY=0, the desired rate might not be achievable.
86 idle_gen_conf = dict(random_idles=True, max_idles=5, zero_idles_chance=50)
87 tb.stream_in.set_idle_generator(ItemRateLimiter(rate_percentage=30, **idle_gen_conf))
88
89 # running simulated reset
90 await tb.reset()
91
92 # starting the BitDriver (randomized DST_RDY)
93 tb.backpressure.start((1, i % 5) for i in itertools.count())
94
95 # dynamically getting the width of the data signal that will be set (useful if the width of the signal may change)
96 data_width = tb.stream_in.item_widths["data"]
97
98 # generating MVB items as random integers between the minimum and maximum unsigned value of the item
99 for transaction in random_integers(0, 2**data_width-1, pkt_count):
100
101 # logging the generated transaction
102 cocotb.log.debug(f"generated transaction: {hex(transaction)}")
103
104 # initializing MVB transaction object
105 mvb_tr = MvbTrClassic()
106
107 # setting data of MVB transaction to the generated integer
108 mvb_tr.data = transaction
109
110 # appending the transaction to tb.expected_output
111 tb.model(mvb_tr)
112
113 # passing the transaction to the driver which then writes it to the bus
114 tb.stream_in.append(mvb_tr)
115
116 last_num = 0
117
118 # checking if all the expected packets have been received
119 while (tb.stream_out.item_cnt < pkt_count):
120
121 # logging number of received packets after every 1000 packets
122 if (tb.stream_out.item_cnt // 1000) > last_num:
123 last_num = tb.stream_out.item_cnt // 1000
124 cocotb.log.info(f"Number of transactions processed: {tb.stream_out.item_cnt}/{pkt_count}")
125
126 # if not all packets have been received yet, waiting 100 cycles so the simulation doesn't stop prematurely
127 await ClockCycles(dut.CLK, 100)
128
129 # logging values measured by throughput probe
130 tb.throughput_probe.log_max_throughput()
131 tb.throughput_probe.log_average_throughput()
132
133 # displaying result of the test
134 raise tb.scoreboard.result
Test Flow
A typical test follows these steps:
Start clock - Initialize the clock generator using
cocotb.start_soon(Clock(...).start()). The clock drives the synchronous logic of the DUT.Initialize testbench - Create the testbench object, which sets up all drivers, monitors, and the scoreboard.
Reset - Run the hardware reset sequence (typically 8-16 clock cycles with RESET high). This ensures the DUT starts in a known state.
Configure stimulus - Set up idle generators (to create realistic gaps in data) and backpressure drivers (to test DUT behavior when output is blocked).
Generate and send data - Create random transactions using helper functions like
random_transactionsor custom generators. Send them to the DUT via the driver’sappend()method.Model expected output - For each sent transaction, compute what the DUT should output and add it to the
expected_outputlist. This is typically done in amodel()method.Wait for completion - Use a waiting loop to ensure all transactions are processed before checking results. Without this, the scoreboard might evaluate prematurely.
Check results - Raise
tb.scoreboard.resultto display pass/fail. The scoreboard automatically compares each received transaction against the expected output.
Required Files
To run a cocotb test, you need these files in your cocotb/ folder:
- pyproject.toml - Python dependencies
This file declares the Python packages required for the test (cocotb, cocotbext-ndk, etc.). The build system uses it to create a virtual environment with all dependencies.
1[project] 2name = "cocotb-hash-table-simple-test" 3version = "0.1.0" 4dependencies = [ 5 "cocotbext-ofm @ ${NDK_FPGA_COCOTBEXT_OFM_URL}", 6 "setuptools", 7] 8 9[build-system] 10requires = ["pdm-backend"] 11build-backend = "pdm.backend"
- cocotb_test_sig.fdo - Simulator waveform signals
This script defines which signals will be visible in the simulator’s waveform viewer. Use it to debug failing tests by inspecting signal timing.
1# coctb_test_sig.fdo : Include file with signals 2# Copyright (C) 2024 CESNET z. s. p. o. 3# Author(s): Jakub Cabal <cabal@cesnet.cz> 4# 5# SPDX-License-Identifier: BSD-3-Clause 6 7view wave 8delete wave * 9 10add_wave -group {ALL} -noupdate -hex /mvb_fifox/* 11 12config wave -signalnamewidth 1
- Makefile - Build and run configuration
The Makefile specifies the simulator to use (Modelsim, Vivado, etc.), the top-level entity, and cocotb configuration. It handles building the simulation and running the test.
1# Makefile: Makefile to compile module 2# Copyright (C) 2024 CESNET z. s. p. o. 3# Author(s): Ondřej Schwarz <Ondrej.Schwarz@cesnet.cz> 4# 5# SPDX-License-Identifier: BSD-3-Clause 6 7TOP_LEVEL_ENT=mvb_fifox 8TARGET=cocotb 9 10.PHONY: all 11all: comp 12 13include ../../../../../build/Makefile
Note
Adjust component-specific values (TOPLEVEL, generics, parameters) and relative paths in all files to match your component.
Running the Test
Create Python virtual environment:
make cocotb-venvThis creates a virtual environment (typically in
venv-<hash>/) with all dependencies frompyproject.toml.Activate the environment:
source venv-xxx/bin/activate
Replace
venv-xxxwith the actual virtual environment folder name.Run the test:
make
This builds the simulation (if needed) and runs the cocotb test. Results are printed to the terminal, and waveforms are saved for debugging.
To run the test in console-only mode (without launching the GUI waveform viewer), use:
make SIM_FLAGS=-c
This is useful for automated testing or when running tests on remote servers.
Tip
Use export COCOTB_LOG_LEVEL=DEBUG before running to enable debug logging for troubleshooting. See the Debug Logging section for more details.
Tip
If a test fails, examine the waveform file to understand the timing and identify the issue. The signals defined in cocotb_test_sig.fdo will be visible.
In case of having trouble with the automation, the test can also be run manually by following the subsequent steps.
Create Python virtual environment:
To manually create the virtual environment, issue:
python<version> -m venv venv-xxx
Use
python3.11as this is the mainline NDK-FPGA Python version.Activate the environment:
source venv-xxx/bin/activate
Fetch the depedencies:
source <ndk-fpga>/env.sh && pip install .
Run the test:
Run the test as described above.
See Also
Cocotb tips & tricks - Tips for debug logging, random seed control, and optional signals
cocotbext-ndk - Overview of cocotbext-ndk extension with drivers, monitors, and utilities