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'scontructor is private; it’s arguments are documented for completeness.- Parameters:
mod (Module) – The
modulefixture.clks (None or float or dict of str: float) – The
clock periodsfixture.req (FixtureRequest) – The
pytestrequestfixture.cfg (Config) – The
pytestpytestconfig()fixture.
- Raises:
Valuerror – If clocks aren’t
None,float, ordictofstr: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 givenmodby driving the giventestbenchesandprocesses.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 raiseAssertionErrorto indicate test failure of a givenmod.- Parameters:
testbenches (list of Callable[[SimulatorContext], Coroutine] or
Testbench) –List of Amaranth
testbenchesto 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
processesto add all at once before running the simulator.
- Raises:
ValueError – If at least one list element of
testbenchesisn’t a callable orTestbench.
- fixture pytest_amaranth_sim.plugin.clks
Scope: function
Fixture representing the clocks used by the
modfixture.The
clksfixture should return either:None, indicating a purely combinational module.A
floatrepresenting the clock period of thesyncdomain in seconds.A
dictwithstrkeys andfloatvalues. The keys name each clock domain used by themodfixture (and thus available tosim). Each value is the clock period of the named clock in seconds.
This fixture is expected to be overridden by the user if the
modfixture is clocked.
- fixture pytest_amaranth_sim.plugin.mod
Scope: function
Fixture representing an Amaranth Module.
If the
simfixture 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
modfixture was not overridden when thesimfixture is used in a test, directly or indirectly.
- fixture pytest_amaranth_sim.plugin.sim
Scope: function
Fixture representing an Amaranth
pysimcontext.- Parameters:
mod (Module) – The
modulefixture.clks (float or dict of str: float) – The
clock periodsfixture.request (FixtureRequest) – The
pytestrequestfixture.
- Return type:
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 thatsim fixturecan pass additional arguments toadd_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, markconstructoras background when passed toadd_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 probablyclksas 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
requestfixture. This is useful when paired withpytest.mark.parametrizeor 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_tbin the below snippet) and return your testbenches and processes to be passed tosim.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).