Usage

Why Make A Sim Wrapper At All?

As alluded to in the quickstart, pytest-amaranth-sim doesn’t do that much on its own. However, I’ve found pytest fixtures to be extremely effective in deduplicating test setup code and to create more test cases. So I went ahead and created this plugin based on my own experiences repeatedly implementing small fixtures, so that all my shared code is in one place.

Fixtures

pytest-amaranth-sim provides the following fixtures and class (SimulatorFixture) for controlling Amaranth simulations:

class pytest_amaranth_sim.plugin.SimulatorFixture(mod, clks, req, cfg)

Fixture class which drives Amaranth’s Python simulator.

SimulatorFixture's contructor is private; it’s arguments are documented for completeness.

Parameters:
Raises:

Valuerror – If clocks aren’t None, float, or dict of str: float.

run(*, testbenches=[], processes=[])

Run a simulation using Amaranth’s amaranth.sim.Simulator.

run() is expected to be called as the last statement in a test. The simulator tests a given mod by driving the given testbenches and processes.

Testbenches and can be prepared and parameterized in multiple ways. See How To Use These Fixtures for examples.

Any exceptions raised within the testbenches and processes given to run() will be propagated to the pytest test runner. Generally, testbenches and processes should raise AssertionError to indicate test failure of a given mod.

Parameters:
  • testbenches (list of Callable[[SimulatorContext], Coroutine] or Testbench) –

    List of Amaranth testbenches to add all at once before running the simulator. The list can be callables, Testbenches, or a mixture.

    Each “bare” callable will be passed to add_testbench() unmodified; such testbenches are implicitly critical.

  • processes (list of Callable[[SimulatorContext], Coroutine]) – List of Amaranth processes to add all at once before running the simulator.

Raises:

ValueError – If at least one list element of testbenches isn’t a callable or Testbench.

fixture pytest_amaranth_sim.plugin.clks

Scope: function

Fixture representing the clocks used by the mod fixture.

The clks fixture should return either:

  • None, indicating a purely combinational module.

  • A float representing the clock period of the sync domain in seconds.

  • A dict with str keys and float values. The keys name each clock domain used by the mod fixture (and thus available to sim). Each value is the clock period of the named clock in seconds.

This fixture is expected to be overridden by the user if the mod fixture is clocked.

Return type:

None or float or dict

fixture pytest_amaranth_sim.plugin.mod

Scope: function

Fixture representing an Amaranth Module.

If the sim fixture is used in a test, either directly or indirectly, this fixture must be overridden by the user. The overridden fixture should return an Amaranth Module.

Raises:

pytest.UsageError – If mod fixture was not overridden when the sim fixture is used in a test, directly or indirectly.

fixture pytest_amaranth_sim.plugin.sim

Scope: function

Fixture representing an Amaranth pysim context.

Parameters:
Return type:

SimulatorFixture

Miscellaneous

pytest_amaranth_sim also provides the following classes, functions, etc for working with its fixtures. Since these are not fixtures, and do not use pytest hooks, you must import them before use.

class pytest_amaranth_sim.Testbench(constructor: Callable[[SimulatorContext], Coroutine], background: bool = False)

Annotate an Amaranth testbench with arguments.

This class is a wrapper, similar to how pytest.param() annotates fixture parameters. Wrap your testbench constructor with this class so that sim fixture can pass additional arguments to add_testbench() along with your testbench constructor.

>>> from pytest_amaranth_sim import Testbench

>>> async def my_tb(ctx):
...    ...

>>> tb = Testbench(my_tb, background=True)
background: bool = False

If True, mark constructor as background when passed to add_testbench().

constructor: Callable[[SimulatorContext], Coroutine]

Testbench constructor- i.e. the first argument to add_testbench().

How To Use These Fixtures

A basic test using this plugin looks something like this:

import pytest
from amaranth import Elaboratable, Module


class MyMod(Elaboratable):
    def __init__(self, width=4, registered=True):
        ...

    def elaborate(self, plat):
        m = Module()

        ...

        return m


@pytest.fixture
def tb(mod, request):
    async def inner(sim):
        s = sim
        m = mod

        ...

        # Use s object to drive simulation forward.
        await s.tick()

        # Assert statements to test m.
        assert ...

    return inner


@pytest.mark.parametrize("mod,clks", [(MyMod(), 1.0 / 12e6)])
def test_tb(sim, tb):
    sim.run(testbenches=[tb])

Todo

Work on the prose of this section. Bullet points are a shortcut.

Of note:

  • Tests are expected to end with sim.run(), where most of the actual test is executed.

  • Tests must be parameterized at least in terms of mod, and probably clks as well (unless testing combinational code).

  • From the simulator’s POV, testbenches and processes are expected to be a function of a single argument (async) or no argument (generator).

Due to the simulator expecting functions of a single or no argument, testbenches and processes generally are defined as inner functions, returned from an outer function (or passed directly to sim.run()). In the above snippet, the inner function is inner and the outer function is tb.

By using inner functions, testbenches and processes can be customized from multiple sources:

  • The request fixture. This is useful when paired with pytest.mark.parametrize or indirect parameterization:

    @pytest.fixture(params=[False, True], ids=["once", "twice"])
    def params_from_fixture_tb(mod, request):
        tick_twice = request.param
    
        async def inner(sim):
            s = sim
            m = mod
    
            ...
    
            # Use s object to drive simulation forward.
            await s.tick()
    
            # Supplying arguments from the request fixture customizes TB behavior.
            if tick_twice:
                await s.tick()
    
            # Assert statements to test m.
            assert ...
    
        return inner
    
    
    @pytest.mark.parametrize("mod,clks", [(MyMod(), 1.0 / 12e6)])
    def test_params_from_fixture_tb(sim, mod, params_from_fixture_tb):
        sim.run(testbenches=[params_from_fixture_tb])
    
  • From other fixtures when parameterizing tests directly:

    @pytest.fixture
    def tick_twice_fixture():
      return False
    
    
    @pytest.fixture
    def params_from_other_fixture_tb(mod, tick_twice_fixture):
        async def inner(sim):
            s = sim
            m = mod
    
            ...
    
            # Use s object to drive simulation forward.
            await s.tick()
    
            # Supplying arguments from an external fixture customizes TB behavior.
            if tick_twice:
                await s.tick()
    
            # Assert statements to test m.
            assert ...
    
        return inner
    
    
    @pytest.mark.parametrize("mod,clks", [(MyMod(), 1.0 / 12e6)])
    @pytest.mark.parametrize("tick_twice_fixture", [True, False])
    def test_params_from_other_fixture_tb(sim, mod, params_from_other_fixture_tb):
        sim.run(testbenches=[params_from_other_fixture_tb])
    
  • Direct input arguments to the outer function. This is useful when the outer function isn’t a fixture, and is very similar to the direct test parameterization above. The outer function would be invoked inside a test body (direct_arg_tb in the below snippet) and return your testbenches and processes to be passed to sim.run():

    def direct_arg_tb(mod, tick_twice=False):
        async def inner(sim):
            s = sim
            m = mod
    
            ...
    
            # Use s object to drive simulation forward.
            await s.tick()
    
            # Supplying arguments to the TB generator customizes TB behavior.
            if tick_twice:
                await s.tick()
    
            # Assert statements to test m.
            assert ...
    
        return inner
    
    
    @pytest.mark.parametrize("mod,clks", [(MyMod(), 1.0 / 12e6)])
    @pytest.mark.parametrize("tick_twice", [True, False])
    def test_direct_arg_tb(sim, mod, tick_twice):
        tb = direct_arg_tb(mod, tick_twice)
    
        sim.run(testbenches=[tb])
    
  • If your given test body has enough fixtures and parameterization, the test itself can be the outer function, and testbenches and processes can be defined in-line in the test body!

    @pytest.mark.parametrize("mod,clks", [(MyMod(), 1.0 / 12e6)])
    @pytest.mark.parametrize("tick_twice", [True, False])
    def test_inline_tb(sim, mod, tick_twice):
        async def inner(sim):
            s = sim
            m = mod
    
            ...
    
            # Use s object to drive simulation forward.
            await s.tick()
    
            # Supplying bound variables to the inner function customizes TB behavior.
            if tick_twice:
                await s.tick()
    
            # Assert statements to test m.
            assert ...
    
        sim.run(testbenches=[inner])
    

    Note the similarities of each inner function, from the basic example to the inline example. How to customize your testbenches is at least partially a matter of preference. I would start with whatever seems quickest to implement and adapt as you flesh out your test suite.

Command Line Options

  • --vcds: Generate Value Change Dump files from simulations. These can be viewed in a VCD viewer like GTKWave or Surfer. The filenames of the VCD files will be derived from the names of tests run in the current session.

Configuration File Settings

The following configuration options are available:

  • long_vcd_filenames: VCD and GTKW files generated have longer, but less ambiguous filenames (bool).

  • extend_vcd_time: Work around GTKWave behavior to truncate VCD traces that end on a transition (string, femtoseconds to extend trace).