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:

  1. Create a ``cocotb`` folder in the directory of the component you want to test

  2. Copy template files from an existing test (e.g., comp/mvb_tools/storage/fifox/cocotb/)

  3. Modify the files for your component: - Update the TOPLEVEL in 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 component

  4. Run 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

  • async function definition (required) - Enables coroutine-based simulation

  • Test 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 cocotb
  9from cocotb.clock import Clock
 10from cocotb.triggers import RisingEdge, ClockCycles
 11from cocotbext.ofm.mvb.drivers import MVBDriver
 12from cocotbext.ofm.mvb.monitors import MVBMonitor
 13from cocotbext.ofm.ver.backpressure import BackpressureGenerator, BackpressureConfig
 14from cocotbext.ofm.ver.generators import random_integers
 15from cocotb_bus.drivers import BitDriver
 16from cocotb_bus.scoreboard import Scoreboard
 17from cocotbext.ofm.utils.throughput_probe import ThroughputProbe, ThroughputProbeMvbInterface
 18from cocotbext.ofm.base.generators import ItemRateLimiter
 19from cocotbext.ofm.mvb.transaction import MvbTrClassic
 20
 21
 22# definition of the class encapsulating components of the test
 23class testbench():
 24    # dut = device tree of the tested component
 25    def __init__(self, dut, debug=False):
 26        self.dut = dut
 27
 28        # setting up the input driver and connecting it to signals beginning with "RX"
 29        self.stream_in = MVBDriver(dut, "RX", dut.CLK)
 30
 31        # setting up the output monitor and connecting it to signals beginning with "TX"
 32        self.stream_out = MVBMonitor(dut, "TX", dut.CLK, tr_type=MvbTrClassic)
 33
 34        # setting up driver of the DST_RDY so it randomly fluctuates between 0 and 1
 35        self.backpressure = BitDriver(dut.TX_DST_RDY, dut.CLK)
 36
 37        # setting up the probe measuring throughput
 38        self.throughput_probe = ThroughputProbe(ThroughputProbeMvbInterface(self.stream_out), throughput_units="items")
 39        self.throughput_probe.set_log_period(10)
 40        self.throughput_probe.add_log_interval(0, None)
 41
 42        # counter of sent transactions
 43        self.pkts_sent = 0
 44
 45        # list of the transactions that are expected to be received
 46        self.expected_output = []
 47
 48        # setting up a scoreboard which compares received transactions with the expected transactions
 49        self.scoreboard = Scoreboard(dut)
 50
 51        # linking a monitor with its expected output
 52        self.scoreboard.add_interface(self.stream_out, self.expected_output)
 53
 54        # setting up the logging level
 55        if debug:
 56            self.stream_in.log.setLevel(cocotb.logging.DEBUG)
 57            self.stream_out.log.setLevel(cocotb.logging.DEBUG)
 58
 59    # method for adding transactions to the expected output
 60    def model(self, transaction):
 61        """Model the DUT based on the input transaction"""
 62        self.expected_output.append(transaction)
 63        self.pkts_sent += 1
 64
 65    # method preforming a hardware reset
 66    async def reset(self):
 67        self.dut.RESET.value = 1
 68        await ClockCycles(self.dut.CLK, 10)
 69        self.dut.RESET.value = 0
 70        await RisingEdge(self.dut.CLK)
 71
 72
 73# defining a test - functions with "@cocotb.test()" decorator will be automatically found and run
 74@cocotb.test()
 75async def run_test(dut, pkt_count=10000):
 76    # start a clock generator
 77    cocotb.start_soon(Clock(dut.CLK, 5, units="ns").start())
 78
 79    # initialization of the test bench
 80    tb = testbench(dut, debug=False)
 81
 82    # change MVB driver's IdleGenerator to ItemRateLimiter
 83    # note: the RateLimiter's rate is affected by backpressure (DST_RDY).
 84    # Even though it takes into account cycles with DST_RDY=0, the desired rate might not be achievable.
 85    idle_gen_conf = dict(random_idles=True, max_idles=5, zero_idles_chance=50)
 86    tb.stream_in.set_idle_generator(ItemRateLimiter(rate_percentage=30, **idle_gen_conf))
 87
 88    # running simulated reset
 89    await tb.reset()
 90
 91    # starting the BitDriver (randomized DST_RDY)
 92    tb.backpressure.start(BackpressureGenerator(BackpressureConfig(1, 5, 0.5)))
 93
 94    # dynamically getting the width of the data signal that will be set (useful if the width of the signal may change)
 95    data_width = tb.stream_in.item_widths["data"]
 96
 97    # generating MVB items as random integers between the minimum and maximum unsigned value of the item
 98    for transaction in random_integers(0, 2**data_width-1, pkt_count):
 99
100        # logging the generated transaction
101        cocotb.log.debug(f"generated transaction: {hex(transaction)}")
102
103        # initializing MVB transaction object
104        mvb_tr = MvbTrClassic()
105
106        # setting data of MVB transaction to the generated integer
107        mvb_tr.data = transaction
108
109        # appending the transaction to tb.expected_output
110        tb.model(mvb_tr)
111
112        # passing the transaction to the driver which then writes it to the bus
113        tb.stream_in.append(mvb_tr)
114
115    last_num = 0
116
117    # checking if all the expected packets have been received
118    while (tb.stream_out.item_cnt < pkt_count):
119
120        # logging number of received packets after every 1000 packets
121        if (tb.stream_out.item_cnt // 1000) > last_num:
122            last_num = tb.stream_out.item_cnt // 1000
123            cocotb.log.info(f"Number of transactions processed: {tb.stream_out.item_cnt}/{pkt_count}")
124
125        # if not all packets have been received yet, waiting 100 cycles so the simulation doesn't stop prematurely
126        await ClockCycles(dut.CLK, 100)
127
128    # logging values measured by throughput probe
129    tb.throughput_probe.log_max_throughput()
130    tb.throughput_probe.log_average_throughput()
131
132    # displaying result of the test
133    raise tb.scoreboard.result

Test Flow

A typical test follows these steps:

  1. Start clock - Initialize the clock generator using cocotb.start_soon(Clock(...).start()). The clock drives the synchronous logic of the DUT.

  2. Initialize testbench - Create the testbench object, which sets up all drivers, monitors, and the scoreboard.

  3. Reset - Run the hardware reset sequence (typically 8-16 clock cycles with RESET high). This ensures the DUT starts in a known state.

  4. Configure stimulus - Set up idle generators (to create realistic gaps in data) and backpressure drivers (to test DUT behavior when output is blocked).

  5. Generate and send data - Create random transactions using helper functions like random_transactions or custom generators. Send them to the DUT via the driver’s append() method.

  6. Model expected output - For each sent transaction, compute what the DUT should output and add it to the expected_output list. This is typically done in a model() method.

  7. Wait for completion - Use a waiting loop to ensure all transactions are processed before checking results. Without this, the scoreboard might evaluate prematurely.

  8. Check results - Raise tb.scoreboard.result to 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

  1. Create Python virtual environment:

    make cocotb-venv
    

    This creates a virtual environment (typically in venv-<hash>/) with all dependencies from pyproject.toml.

  2. Activate the environment:

    source venv-xxx/bin/activate
    

    Replace venv-xxx with the actual virtual environment folder name.

  3. 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.

  1. Create Python virtual environment:

    To manually create the virtual environment, issue:

    python<version> -m venv venv-xxx
    

    Use python3.11 as this is the mainline NDK-FPGA Python version.

  2. Activate the environment:

    source venv-xxx/bin/activate
    
  3. Fetch the depedencies:

    source <ndk-fpga>/env.sh && pip install .
    
  4. 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