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 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:

  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