这是本节的多页打印视图。
点击此处打印.
返回本页常规视图.
编写测试用例
编写测试用例需要使用验证环境中定义好的接口来实现,但在用例中,往往会遇到同时驱动多个接口的情况,并且对于参考模拟的同步往往也有不同的需求,这一部分将详细介绍如何更好地使用验证环境中的接口来编写测试用例。
当验证环境搭建完成后,编写测试用例用于验证设计的功能是否符合预期。对于硬件验证中的验证,两个重要的导向是:功能覆盖率和行覆盖率,功能覆盖率意味着测试用例是否覆盖了设计的所有功能,行覆盖率意味着测试用例是否触发了设计的所有代码行。在 toffee-test 中,不仅提供了对这两种覆盖率的支持,还会再每次运行过后,自动计算出这两种覆盖率的结果,并生成一个验证报告。toffee-test 使用 pytest 来管理测试用例,使其拥有强大的测试用例管理能力。
在本节中,会在以下几个方面来讲述如何编写测试用例,以使用 toffee 和 toffee-test 提供的强大功能:
- 如何使用测试环境接口进行驱动
- 如何使用 pytest 管理测试用例
- 如何添加功能测试点
1 - 如何使用测试环境接口进行驱动
如何同时调用多个驱动函数
当验证环境搭建完成后,可以通过验证环境提供的接口来编写测试用例。然而,通过普通的串行代码,往往无法完成两个驱动函数的同时调用。在多个接口需要同时驱动的情况下,这种情况变得尤为重要,toffee 为这种场景提供了简便的调用方式。
同时调用多个不同类别的驱动函数
例如目前的 Env 结构如下:
DualPortStackEnv
- port1_agent
- @driver_method push
- @driver_method pop
- port2_agent
- @driver_method push
- @driver_method pop
我们期望在测试用例中同时调用 port1_agent
和 port2_agent
的 push
函数,以便同时驱动两个接口。
在 toffee 中,可以通过 Executor
来完成。
from toffee import Executor
def test_push(env):
async with Executor() as exec:
exec(env.port1_agent.push(1))
exec(env.port2_agent.push(2))
print("result", exec.get_results())
我们使用 async with
来创建一个 Executor
对象,并建立一个执行块,通过直接调用 exec
可以添加需要执行的驱动函数。当 Executor
对象退出作用域时,会将所有添加的驱动函数同时执行。Executor
会自动等待所有驱动函数执行完毕。
如果需要获取驱动函数的返回值,可以通过 get_results
方法来获取,get_results
会以字典的形式返回所有驱动函数的返回值,其中键为驱动函数的名称,值为一个列表,列表中存放了对应驱动函数的返回值。
同一驱动函数被多次调用
如果在在执行块中多次调用同一驱动函数,Executor
会自动将这些调用串行执行。
from toffee import Executor
def test_push(env):
async with Executor() as exec:
for i in range(5):
exec(env.port1_agent.push(1))
exec(env.port2_agent.push(2))
print("result", exec.get_results())
例如上述代码中,port1_agent.push
会被调用 5 次,port2_agent.push
会被调用 1 次。由于 port1_agent.push
是同一驱动函数,Executor
会自动将这 10 次调用串行执行,其返回值会被依次存放在返回值列表中。通过,port2_agent.push
将会与 port1_agent.push
并行执行。
上述过程中,我们创建了这样一个调度过程:
------------------ current time --------------------
+---------------------+ +---------------------+
| group "agent1.push" | | group "agent2.push" |
| +-----------------+ | | +-----------------+ |
| | agent1.push | | | | agent2.push | |
| +-----------------+ | | +-----------------+ |
| +-----------------+ | +---------------------+
| | agent1.push | |
| +-----------------+ |
| +-----------------+ |
| | agent1.push | |
| +-----------------+ |
| +-----------------+ |
| | agent1.push | |
| +-----------------+ |
| +-----------------+ |
| | agent1.push | |
| +-----------------+ |
+---------------------+
------------------- Executor exit -------------------
Executor 根据两个驱动函数的函数名自动创建了两个调度组,并按照调用顺序将驱动函数添加到对应的调度组中。在调度组内部,驱动函数会按照添加的顺序依次执行。在调度组之间,驱动函数会并行执行。
调度组的默认名称为以 .
分隔的驱动函数路径名。
通过 sche_group
参数,你可以在执行函数时手动指定驱动函数调用时所属的调度组,例如
from toffee import Executor
def test_push(env):
async with Executor() as exec:
for i in range(5):
exec(env.port1_agent.push(1), sche_group="group1")
exec(env.port2_agent.push(2), sche_group="group1")
print("result", exec.get_results())
这样一来,port1_agent.push
和 port2_agent.push
将会被按顺序添加到同一个调度组 group1
中,表现出串行执行的特性。同时 get_results
返回的字典中,group1
会作为键,其值为一个列表,列表中存放了 group1
中所有驱动函数的返回值。
将自定义函数加入 Executor
如果我们在一个自定义函数中调用了驱动函数或其他驱动函数,并希望自定义函数也可以通过 Executor
来调度,可以通过与添加驱动函数相同的方式来添加自定义函数。
from toffee import Executor
async def multi_push_port1(env, times):
for i in range(times):
await env.port1_agent.push(1)
async def test_push(env):
async with Executor() as exec:
for i in range(2):
exec(multi_push_port1(env, 5))
exec(env.port2_agent.push(2))
print("result", exec.get_results())
此时,multi_push_port1
会被添加到 Executor
中,并创建以 multi_push_port1
为名称的调度组,并向其中添加两次调用。其会与 port2_agent.push
调度组并行执行。
我们也可以在自定义函数中使用 Executor
,或调用其他自定义函数。这样一来,我们可以通过 Executor
完成任意复杂的调度。以下提供了若干个案例:
案例一
环境接口如下:
Env
- agent1
- @driver_method send
- agent2
- @driver_method send
两个 Agent 中的 send
函数各需要被并行调用 5 次,并且调用时需要发送上一次的返回结果,第一次发送时发送 0,两个函数调用相互独立。
from toffee import Executor
async def send(agent):
result = 0
for i in range(5):
result = await agent.send(result)
async def test_send(env):
async with Executor() as exec:
exec(send(env.agent1), sche_group="agent1")
exec(send(env.agent2), sche_group="agent2")
print("result", exec.get_results())
案例二
环境接口如下:
env
- agent1
- @driver_method long_task
- agent2
- @driver_method task1
- @driver_method task2
task1 和 task2 需要并行执行,并且一次调用结束后需要同步,task1 和 task2 都需要调用 5 次,long_task 需要与 task1 和 task2 并行执行。
from toffee import Executor
async def exec_once(env):
async with Executor() as exec:
exec(env.agent2.task1())
exec(env.agent2.task2())
async def test_case(env):
async with Executor() as exec:
for i in range(5):
exec(exec_once(env))
exec(env.agent1.long_task())
print("result", exec.get_results())
设置 Executor 的退出条件
Executor 会等待所有添加的驱动函数执行完毕后退出,但有时我们并不需要等待所有驱动函数执行完毕,可以通过在创建 Executor 时使用 exit
参数来设置退出条件。
exit
参数可以被设置为 all
, any
或 none
三种值,分别表示所有调度组执行完毕后退出、任意一个调度组执行完毕后退出、不等待直接退出。
from toffee import Executor
async def send_forever(agent):
result = 0
while True:
result = await agent.send(result)
async def test_send(env):
async with Executor(exit="any") as exec:
exec(send_forever(env.agent1))
exec(env.agent2.send(1))
print("result", exec.get_results())
例如上述代码中 send_forever
函数是一个无限循环的函数,将 exit
设置为 any
后,Executor 会在 env.agent2.send
函数执行完毕后退出,而不会等待 send_forever
函数执行完毕。
如果后续需要等待所有任务执行完毕,可以通过等待 exec.wait_all
来实现。
如何控制参考模型调度
在 toffee 中,参考模型的调度是由 toffee 自动完成的,但在某些情况下需要手动控制参考模型的调度顺序,例如在参考模型中需要调用多个函数,且这些函数之间存在调用顺序的情况。或者是控制参考模型与驱动函数之间的调用顺序。
参考模型的调度顺序
在使用 Executor 执行时,可以使用参数 sche_order
来控制参考模型是在驱动函数之前、之后或同时执行。当为 model_first
时,参考模型会在驱动函数之前执行;当为 dut_first
时,驱动函数会在参考模型之前执行;当为 parallel
时,参考模型会与驱动函数同时执行。默认情况下为并行执行。
def test_push(env):
async with Executor() as exec:
exec(env.port1_agent.push(1), sche_order="dut_first")
exec(env.port2_agent.push(2), sche_order="dut_first")
print("result", exec.get_results())
上述代码中,参考模型将会在对应的驱动函数结束之后才会被调用。
参考模型函数之间的调用顺序
当使用函数调用模式编写参考模型时,参考模型中的函数之间可能存在调用顺序相关的一来,例如一个函数在调用之前必须需要另一个函数先被调用。
这一过程若不使用 Executor 使函数并行执行,很容易得到控制,串行执行的代码中函数的调用顺序即为其执行顺序。
但如果使用 Executor 并行执行函数,两个参考模型之间的调用顺序就无法保证。toffee 为此场景提供了 priority
参数,用于指定参考模型函数的调用顺序,数值越小其优先级较高。
from toffee import Executor
def test_push(env):
async with Executor() as exec:
exec(env.port1_agent.push(1), priority=1)
exec(env.port2_agent.push(2), priority=0)
print("result", exec.get_results())
例如上述代码中,port2_agent.push
和 port1_agent.push
两个函数会并行执行,其参考模型的调用也将在同一时钟周期内完成。由于我们指定了port2_agent.push
的优先级为 0,port1_agent.push
的优先级为 1,因此在该周期的执行过程中,port2_agent.push
会优先被调用。
注意,优先级只在同一时钟周期内有效,若两个函数调用跨越了时钟周期,那么时钟周期靠前的函数依然会被优先调用。
2 - 如何使用 Pytest 管理测试用例
编写测试用例
在 toffee 中,测试用例是通过 pytest 来管理的。pytest 是一个功能强大的 Python 测试框架,如果你不熟悉 pytest,可以查看 pytest 官方文档。
编写第一个测试用例
首先,我们需要创建一个测试用例文件,例如 test_adder.py
,该文件需要以 test_
开头,或以 _test.py
结尾,以便 pytest 能够识别。接着可以在其中编写我们的第一个测试用例。
# test_adder.py
async def my_test():
env = AdderEnv()
env.add_agent.exec_add(1, 2, 0)
def test_adder():
toffee.run(my_test())
pytest 并不能直接运行协程测试用例,因此我们需要在测试用例中调用 toffee.run
来运行异步测试用例。
用例编写完成后,我们可以在终端中运行 pytest。
pytest 会查找当前目录下所有以 test_
开头或以 _test.py
结尾的文件,并运行其中以 test_
开头的函数,每一个函数被视作一个测试用例。
运行协程测试用例
为了使 pytest 能够直接运行协程测试用例,toffee 提供了 toffee_async
标记来标记异步测试用例。
# test_adder.py
@pytest.mark.toffee_async
async def test_adder():
env = AdderEnv(DUTAdder())
await env.add_agent.exec_add(1, 2, 0)
如图所示,我们只需要在测试用例函数上添加 @pytest.mark.toffee_async
标记,pytest 就能够直接运行协程测试用例。
生成测试报告
在运行 pytest 时,toffee 会自动收集测试用例的执行结果,自动统计覆盖率信息,并生成一个验证报告,想要生成该报告,需要在调用 pytest 时添加 --toffee-report
参数。
默认情况下,toffee 将会为每次运行生成一个默认报告名称,并将报告放至 reports
目录下。可以通过 --report-dir
参数来指定报告的存放目录,通过 --report-name
参数来指定报告的名称。
但此时,由于 toffee 无法得知覆盖率文件名称,因此在报告中无法显示覆盖率信息,如果想要在报告中显示覆盖率信息,需要在每个测试用例中传入功能覆盖组及行覆盖率文件的名称。
@pytest.mark.toffee_async
async def test_adder(request):
adder = DUTAdder(
waveform_filename="adder.fst",
coverage_filename="adder.dat"
)
g = CovGroup("Adder")
env = AdderEnv(adder)
await env.add_agent.exec_add(1, 2, 0)
adder.Finish()
set_func_coverage(request, cov_groups)
set_line_coverage(request, "adder.dat")
上述代码中,在创建 DUT 时,我们传入了波形文件和覆盖率文件的名称,使得 DUT 在运行时可以生成指定名称的覆盖率文件。接着我们定义了一个覆盖组,来收集 DUT 的功能覆盖率信息,具体如何使用将在下个文档中介绍。
接着,调用了 DUT 的 Finish
方法,用于结束波形文件的记录。最终我们通过 set_func_coverage
和 set_line_coverage
函数来设置功能覆盖组及行覆盖率文件信息。
此时再次运行 pytest 时,toffee 将会自动收集覆盖率信息,并在报告中显示。
使用 toffee-test 管理资源
然而,上述过程过于繁琐,并且为了保证每个测试用例之间文件名称不产生冲突,我们需要在每个测试用例中传入不一样的文件名称。并且在测试用例出现异常时,测试用例并不会运行完毕,导致覆盖率文件无法生成。
因此,toffee-test 提供了 toffee_request
Fixture 来管理资源,简化了测试用例的编写。
# test_adder.py
@pytest.mark.toffee_async
async def test_adder(my_request):
dut = my_request
env = AdderEnv(dut)
await env.add_agent.exec_add(1, 2, 0)
@pytest.fixture()
def my_request(toffee_request: ToffeeRequest):
toffee_request.add_cov_groups(CovGroup("Adder"))
return toffee_request.create_dut(DUTAdder)
Fixture 是 pytest 中的概念,例如上述代码中定义了一个名为 my_request
的 Fixture。如果在其他测试用例的输出参数中含有 my_request
参数,pytest 将会自动调用 my_request
Fixture,并将其返回值传入测试用例。
上述代码中自定义了一个 Fixture my_request
,并在测试用例中进行使用,这也就意味着资源的管理工作都将会在 Fixture 中完成,测试用例只需要关注测试逻辑即可。my_request
必须使用 toffee-test 提供的 toffee_request
Fixture 作为参数,以便进行资源管理,toffee_request
提供了一系列的方法来管理资源。
通过 add_cov_groups
添加覆盖组,toffee-test 会自动将其生成至报告中。
通过 create_dut
创建 DUT 实例,toffee-test 会自动管理 DUT 的波形文件和覆盖率文件的生成,并确保文件名称不产生冲突。
在 my_request
中,可以自定义返回值传入测试用例中。如果想要任意测试用例都可以访问到该 Fixture,可以将 Fixture 定义在 conftest.py
文件中。
至此,我们实现了测试用例资源管理和逻辑编写的分离,无需在每个测试用例中手动管理资源的创建与释放。
3 - 功能检查点(功能覆盖率)
什么是功能检查点
在 toffee 中,功能检查点(Cover Point) 是指对设计的某个功能进行验证的最小单元,判断该功能是否满足设计目标。测试组(Cover Croup) 是一类检查点的集合。
定义一个检查点,需要指定检查点的名称及检查点的触发条件(触发条件可以有多个,最终的检查结果为所有条件取“逻辑与”,触发条件称为Cover Bin
)。例如,可以定义了一个检查点,“当加法器运算结果不为 0 时,结果运算正确”,此时,检查点的触发条件可以为 “加法器的 sum 信号不为零”。
当检查点的所有触发条件都满足时,检查点被触发,此时,验证报告将会记录下该检查点的触发。并会提升验证的功能覆盖率。当所有检查点都被触发时,验证的功能覆盖率达到 100%。
如何编写检查点
编写检查点前,首先需要创建一个测试组,并指定测试组的名称
import toffee.funcov as fc
g = fc.CovGroup("Group-A")
接着,需要往这个测试组中添加检查点。一般情况下,一个功能点对应一个或多个检查点,用来检查是否满足该功能。例如我们需要检查Adder
的cout
是否有0
出现,我们可以通过如下方式添加:
g.add_watch_point(adder.io_cout,
{"io_cout is 0": fc.Eq(0)},
name="cover_point_1")
在上述检查点中,需要观察的数据为io_cout
引脚,检查条件(Cover Bin
)的名称为io_cout is 0
,检查点名称为cover_point_1
。函数add_watch_point
的参数说明如下:
def add_watch_point(target,
bins: dict,
name: str = "", once=None):
"""
@param target: 检查目标,可以是一个引脚,也可以是一个DUT对象
@param bins: 检查条件,dict格式,key为条件名称,value为具体检查方法或者检查方法的数组。
@param name: 检查点名称
@param once,如果once=True,表明只检查一次,一旦该检查点满足要求后就不再进行重复条件判断。
通常情况下,target
为DUT
引脚,bins
中的检查函数来检查target
的value
是否满足预定义条件。funcov
模块内存了部分检查函数,例如Eq(x), Gt(x), Lt(x), Ge(x), Le(x), Ne(x), In(list), NotIn(list), isInRange([low,high])
等。当内置检查函数不满足要求时,也可以自定义,例如需要跨时钟周期进行检查等。自定义检查函数的输入参数为target
,返回值为bool
。例如:
g.add_watch_point(adder.io_cout,
{
"io_cout is 0": lambda x: x.value == 0,
"io_cout is 1": lambda x: x.value == 1,
"io_cout is x": [fc.Eq(0), fc.In([0,1]), lambda x:x.value < 4],
},
name="cover_point_1")
当添加完所有的检查点后,需要在DUT
的Step
回调函数中调用CovGroup
的sample()
方法进行判断。在检查过程中,或者测试运行完后,可以通过CovGroup
的as_dict()
方法查看检查情况。
dut.StepRis(lambda x: g.sample())
...
print(g.as_dict())
如何在测报告中展示
在测试case
每次运行结束时,可以通过set_func_coverage(request, cov_groups)
告诉框架对所有的功能覆盖情况进行合并收集。相同名字的CoverGroup
会被自动合并。下面是一个简单的例子:
import pytest
import toffee.funcov as fc
from toffee_test.reporter import set_func_coverage
g = fc.CovGroup("Group X")
def init_function_coverage(g):
# add your points here
pass
@pytest.fixture()
def dut_input(request):
# before test
init_function_coverage(g)
dut = DUT()
dut.InitClock("clock")
dut.StepRis(lambda x: g.sample())
yield dut
# after test
dut.Finish()
set_func_coverage(request, g)
g.clear()
def test_case1(dut_input):
assert True
def test_case2(dut_input):
assert True
# ...
在上述例子中,每个case
都会通过dut_input
函数来创建输入参数。该函数用yield
返回dut
,在运行case
前初始化dut
,并且设置在dut
的step
回调中执行g.sample()
。运行完case
后,调用set_func_coverage
收集覆盖率,然后清空收集的信息。所有测试运行完成后,可在生成的测试报告中查看具体的覆盖情况。