ESP-IDF 目标板测试指南基于 Pytest 的 Target Test 编写、运行与 CI 集成【免费下载链接】esp-idfEspressif IoT Development Framework. Official development framework for Espressif SoCs.项目地址: https://gitcode.com/GitHub_Trending/es/esp-idf导读本文围绕 ESP-IDF 的**目标板测试Target Test**体系展开系统讲解如何在真实 ESP 芯片或 Linux 主机、QEMU 模拟器上使用 pytest 框架自动化运行测试用例。你将掌握 test app 与 DUT 的核心概念、idf_parametrize参数化写法、单 DUT/多 DUT/Unity 测试的编写范式、CI 流水线中构建与测试任务的分工以及本地调试与性能统计等实战技巧。全文以 docs/en/contribute/esp-idf-tests-with-pytest.rst 为骨架并结合仓库内真实用例与配置进行源码级佐证。说明ESP-IDF 的 Target Test 默认依赖以下 pytest 插件——pytest-embedded默认启用esp,idf两个服务、pytest-rerunfailures、pytest-ignore-test-results。本文所有概念与用法均基于这些插件的默认行为可能与原生 pytest 的行为有所差异。本文主要面向 ESP-IDF 贡献者部分概念如自定义 marker对使用 ESP-IDF SDK 的个人项目未必直接适用。安装测试依赖ESP-IDF 的测试脚本依赖通过安装脚本按需启用# 安装全部基础依赖pytest、pytest-embedded 等 $ install.sh --enable-ci # 额外安装与测试脚本强相关的依赖如特定组件的测试工具 $ install.sh --enable-test-specificESP-IDF 的 pytest 配置集中在仓库根目录的 pytest.ini 中它通过addopts预置了--embedded-services esp,idf、--strict-markers、--check-duplicates y、JUnit 报告格式等默认行为并声明了markers与env_markers两大类自定义 marker详见后文。安装过程中若遇到问题可在 ESP-IDF 的 issue 追踪器中反馈。核心概念Test App 与 DUTTest App是一组由 IDF 工程构建出的二进制文件用于验证项目的某个特定功能。它们通常位于examples/、tools/test_apps/以及components/COMPONENT_NAME/test_apps/目录下。DUTDevice under Test指连接到主机如 PC的 ESP 芯片集合主机负责烧录固件、触发测试用例并检查测试结果。一个典型包含 pytest 脚本的工程结构如下. └── my_app/ ├── main/ │ └── ... ├── CMakeLists.txt └── pytest_foo.py当某个多 DUT 测试需要多个 test app 时结构会变为. ├── my_app_foo/ │ ├── main/ │ │ └── ... │ └── CMakeLists.txt ├── my_app_bar/ │ ├── main/ │ │ └── ... │ └── CMakeLists.txt └── pytest_foo_bar.py单 DUT 测试用例入门示例以 ESP-IDF 的 get-started 示例为例最简单的单 DUT 测试脚本如下idf_parametrize(target, [esp32, esp32s2], indirect[target]) pytest.mark.generic def test_hello_world(dut) - None: dut.expect(Hello world!)该脚本可直接配合 examples/get-started/hello_world 工程运行。idf_parametrize是pytest.mark.parametrize的封装简化并扩展了基于字符串的测试参数化。target是特殊参数表示目标板类型indirect[target]表示该参数会先于其他 fixture 被预计算。上述示例会让该用例分别在 ESP32 与 ESP32-S2 两块目标板上运行。仓库中真实用例 pytest_hello_world.py 还演示了更精简的写法pytest.mark.generic idf_parametrize(target, [supported_targets, preview_targets], indirect[target]) def test_hello_world(dut: IdfDut, log_minimum_free_heap_size: Callable[..., None]) - None: dut.expect(Hello world!) log_minimum_free_heap_size()关于target参数有几点补充若用例可在 ESP-IDF 官方支持的所有目标上运行可通过idf.py --list-targets查看可直接使用特殊值supported_targets一行即可覆盖全部目标preview_targets与all也是受支持的特殊值idf.py --list-targets --preview可查看含预览目标在内的完整列表。若需要按soc_caps过滤目标可使用soc_filtered_targets例如idf_parametrize(target, soc_filtered_targets(SOC_ULP_SUPPORTED ! 1), indirect[target])表示只选择不支持 ULP 的目标芯片。pytest.mark.generic是环境 marker表示该用例应在通用generic开发板上运行。所有环境 marker 的完整定义见仓库根目录 pytest.ini 的env_markers段如generic、qemu、wifi_router、jtag等均对应 CI runner 的硬件标签。测试函数的dutfixture在单 DUT 用例中是IdfDut类的实例在多 DUT 用例中则是IdfDut实例的元组。在 Linux 主机上运行将target设为linux即可在 Linux 主机上运行同一套测试流程无需真实硬件idf_parametrize(target, [linux], indirect[target]) def test_hello_world_linux(dut) - None: dut.expect(Hello world!)对于纯 Linux 用例设置targetlinux后idf嵌入服务会被自动选中无需再添加pytest.mark.host_testmarker。混合环境矩阵则需为每个用例手动指定embedded_services见后文“同一 App 的不同运行环境”。在 QEMU 中运行为测试函数添加pytest.mark.qemumarker 即可在 QEMU 模拟器中运行pytest.mark.qemu idf_parametrize(target, [esp32, esp32c3], indirect[target]) def test_hello_world_qemu(dut) - None: dut.expect(Hello world!)对于纯 QEMU 用例只需添加pytest.mark.qemuidf,qemu嵌入服务会被自动选中。仓库中的 pytest_hello_world.py 提供了一个 QEMU 下的进阶示例先通过正则从串口输出中提取应用上报的 ELF SHA256 并与本地计算的哈希比对再校验Hello world!输出展示了 QEMU 用例中QemuApp/QemuDut的实际用法。QEMU 的安装与配置可参考仓库内 QEMU 相关文档。pytest.mark.host_test的弃用pytest.mark.host_test已不再需要新用例不应再添加。Linux 与 QEMU 用例所需的运行行为已由测试框架动态处理——对于纯 Linux 与纯 QEMU 用例嵌入服务会被自动选择。同一 App 搭配不同 sdkconfig 文件部分用例需要在不同 sdkconfig 配置下运行同一 App。假设目录结构如下. └── my_app/ ├── main/ │ └── ... ├── CMakeLists.txt ├── sdkconfig.ci.foo ├── sdkconfig.ci.bar └── pytest_foo.py若需要在所有目标上用这两份 sdkconfig 运行可这样写idf_parametrize(target, [ esp32, # -- 使用 esp32 目标运行 esp32s2 # -- 使用 esp32s2 目标运行 ], indirect[target]) pytest.mark.parametrize(config, [ # -- 用该 marker 指定 sdkconfig 文件若不使用则默认取 default由 sdkconfig.ci 或 sdkconfig.ci.default 构建若使用则取指定的 sdkconfig.ci.config如 sdkconfig.ci.foo、sdkconfig.ci.bar foo, # -- 使用 sdkconfig.ci.foo 运行 bar, # -- 使用 sdkconfig.ci.bar 运行 ], indirectTrue) # -- 必须为 True表示该参数在其它 fixture 之前预计算 def test_foo_bar(dut, config) - None: if config foo: dut.expect(This is from sdkconfig.ci.foo) elif config bar: dut.expect(This is from sdkconfig.ci.bar)所有 marker 会同时生效上述测试函数最终会被复制成 4 个测试用例test_foo_baresp32 目标 sdkconfig.ci.footest_foo_baresp32 目标 sdkconfig.ci.bartest_foo_baresp32s2 目标 sdkconfig.ci.footest_foo_baresp32s2 目标 sdkconfig.ci.bar在测试脚本或日志中你可能会看到形如esp32.foo.test_foo_bar的格式这被称为测试用例 IDtest case ID由三部分组成目标名esp32、配置名foo、测试函数名test_foo_bar。测试用例 ID 是该用例的唯一标识也用于 JUnit 报告中定位具体用例。提示pytest-embedded 的几乎全部 CLI 选项都支持参数化。运行pytest --help可查看全部选项embedded-...段对应原生 pytest-embedded 选项idf段对应 ESP-IDF 专属选项。同一 App 搭配不同 sdkconfig、不同目标当不同 sdkconfig 文件分别支持不同目标时可以用单个idf_parametrize同时参数化target与configidf_parametrize( target, config, [ (esp32, foo), (esp32s2, bar) ], indirect[target, config] )该测试函数会被复制成 2 个用例以测试用例 ID 表示esp32.foo.test_foo_bar与esp32s2.bar.test_foo_bar。同一 App 的不同运行环境当同一 App 需要在不同环境验证Linux 主机、真实硬件、QEMU时可在单个idf_parametrize中组合target、config、embedded_services三个参数并为每个用例附加对应 marker。下面的示例改编自 components/console/test_apps/console/pytest_console.pyidf_parametrize( target,config,embedded_services,markers, [ (linux, defaults, idf, ()), (esp32, defaults, esp,idf, (pytest.mark.generic,)), (esp32c3, defaults, esp,idf, (pytest.mark.generic,)), (esp32, defaults, idf,qemu, (pytest.mark.qemu,)), ], indirect[target, config, embedded_services], ) def test_console_repl(dut) - None: dut.expect_exact(Press ENTER to see the list of tests)这为同一 App 创建了 4 个用例Linux 主机执行使用idf服务ESP32 硬件执行使用esp,idf服务ESP32-C3 硬件执行使用esp,idf服务ESP32 在 QEMU 中执行使用idf,qemu服务。本地运行时可以只挑选想要的环境$ pytest --target linux # 只选 Linux 目标用例 $ pytest -m qemu # 选择所有带 qemu marker 的用例 $ pytest -m qemu --target esp32 # 进一步限定为 ESP32 的 QEMU 用例当测试逻辑相同、仅执行环境变化时优先使用该模式。串口输出断言Expecting为了确认目标板上的测试执行成功测试脚本可用dut.expect()检查目标板的串口输出def test_hello_world(dut) - None: dut.expect(\d) # -- 按正则 expect dut.expect_exact(Hello world!) # -- 按字符串精确匹配dut.expect(...)会先把期望字符串编译为正则再在串口输出流中持续搜索直到正则匹配或超时。当期望字符串中包含正则关键字字符如括号、方括号时要格外小心此时可改用dut.expect_exact(...)它会按原字符串精确匹配而不做正则转换。多 DUT 测试用例同一 App 的多目标测试当多个目标运行同一 test app 时把count参数化为 DUT 数量pytest.mark.parametrize(count, [ 2, ], indirectTrue) pytest.mark.parametrize(target, [ esp32|esp32s2, esp32s3, ], indirectTrue) def test_hello_world(dut) - None: dut[0].expect(Hello world!) dut[1].expect(Hello world!)所有参数化条目中的|符号用于分隔每个 DUT 的设置。本例中测试会以两组组合执行dut-0 为 esp32、dut-1 为 esp32s2dut-0 与 dut-1 均为 esp32s3。设置count为 2 后所有 fixture 都会变成元组。注意count是多 DUT 测试的必填参数。不同 App 的多目标测试当多个目标运行不同 test app例如 master/slave 主从架构时可参数化app_path。典型目录结构. ├── master/ │ ├── main/ │ │ └── ... │ └── CMakeLists.txt ├── slave/ │ ├── main/ │ │ └── ... │ └── CMakeLists.txt └── pytest_master_slave.py对应测试脚本pytest.mark.multi_dut_generic pytest.mark.parametrize(count, [ 2, ], indirectTrue) pytest.mark.parametrize(app_path, target, [ (f{os.path.join(os.path.dirname(__file__), master)}|{os.path.join(os.path.dirname(__file__), slave)}, esp32|esp32s2), (f{os.path.join(os.path.dirname(__file__), master)}|{os.path.join(os.path.dirname(__file__), slave)}, esp32s2|esp32), ], indirectTrue) def test_master_slave(dut) - None: master dut[0] slave dut[1] master.write(Hello world!) slave.expect_exact(Hello world!)该用例会被复制成 2 个测试用例dut-0 为 ESP32 运行masterdut-1 为 ESP32-S2 运行slavedut-0 为 ESP32-S2 运行masterdut-1 为 ESP32 运行slave。提示同时参数化两个条目如这里的app_path, target时必须向parametrize传入元组列表每个元组包含各条目的取值。使用 Unity 测试框架的用例ESP-IDF 的单元测试使用 Unity 测试框架整体分为三类用例普通用例单 DUT、多阶段用例单 DUT、多设备用例多 DUT。所有单 DUT 用例含普通与多阶段可用以下方式运行def test_unity_single_dut(dut: IdfDut): dut.run_all_single_board_cases()该命令会跳过所有包含[ignore]标签的用例。其他筛选方式# 运行带 [psram] 标签的一组用例 dut.run_all_single_board_cases(grouppsram) # 运行除 [psram] 之外的所有用例 dut.run_all_single_board_cases(group!psram) # 按属性筛选如 test_env xtal32k dut.run_all_single_board_cases(attributes{test_env: xtal32k}) # 按用例名精确触发 dut.run_all_single_board_cases(name[normal_case1, multiple_stages_test])此外还提供了case_testerfixture可更便捷地触发各类用例def test_unity_single_dut(case_tester): case_tester.run_all_normal_cases() # 运行所有普通用例 case_tester.run_all_multi_dev_cases() # 运行所有多设备用例 case_tester.run_all_multi_stage_cases() # 运行所有多阶段用例在 CI 中运行 Target TestCI 中的目标测试流水线遵循“构建 → 分配测试 → 目标测试 → 汇总报告”的固定工作流buildbuild_test_related_apps / build_non_test_related_apps ↓ assign_testbuild_job_report / generate_pytest_child_pipeline ↓ target_test各具体目标测试任务 ↓ .posttarget_test_report所有 build 任务与 target test 任务均由 CI 脚本 tools/ci/dynamic_pipelines 自动生成。Build 任务CI 中components、examples、tools/test_apps下的所有 ESP-IDF 工程都会用所有支持的目标与 sdkconfig 文件构建二进制产物放在build_target_config目录例如. ├── build_esp32_history/ │ └── ... ├── build_esp32_nohistory/ │ └── ... ├── build_esp32s2_history/ │ └── ... ├── main/ ├── CMakeLists.txt ├── sdkconfig.ci.history ├── sdkconfig.ci.nohistory └── ...Build 任务分两类build_test_related_apps构建产物会上传到内部 MinIO 服务器可在 MR 页面发布的 build 报告中找到下载链接build_non_test_related_apps构建产物在任务结束后删除仅保留构建日志并上传到内部 MinIO同样可在 build 报告中找到链接。依赖驱动的构建为优化 CI 构建时间ESP-IDF 使用 idf-build-apps 的依赖驱动构建特性只构建受变更组件影响的 App。依赖规则定义在各目录的.build-test-rules.yml清单文件中每个 App 可声明depends_componentsexamples/foo/bar: depends_components: - esp_eth - esp_netif同时仓库还通过 .idf_build_apps.toml 定义了一组common_components被众多 App 使用的基础/核心组件清单。一般来说若这些组件之一发生变化通常需要重建并重测依赖它的 App。App 维护者应自行决定哪些组件对其 App 重要若 App 应依赖某个common_components就将其加入depends_components否则只声明重要组件。若未指定depends_components则使用计算出的组件来自project_description.json来判定 App 是否受变更组件影响。已废弃的deactivate_dependency_driven_build_by_components在特定组件变化时关闭依赖驱动检查建议改用depends_components与common_components。Target Test 任务CI 中所有 target test 任务按targets - env_markers模式命名例如单 DUT 任务esp32 - generic、多 DUT 任务esp32,esp32 - multi_dut_generic。任务中的二进制从内部 MinIO 下载大多数用例只下载烧录所需文件如 .bin、flash_args 等部分用例如 JTAG 用例还会额外下载 .elf 文件。本地运行测试安装首先按前文方式安装带 CI 依赖的 ESP-IDF 并导出环境$ cd $IDF_PATH $ bash install.sh --enable-ci $ . ./export.sh构建目录查找规则默认情况下每个用例会按以下顺序查找所需二进制文件所在目录--build-dir命令行参数指定的目录若指定build_target_sdkconfigbuild_targetbuild_sdkconfigbuild。只要上述目录存在一个用例就会使用该目录下的二进制进行烧录若全部不存在用例将报错失败。测试你的测试脚本带sdkconfig.defaults的单 DUT 用例最简单场景——以 examples/get-started/hello_world 为例假设使用 ESP32 开发板$ cd $IDF_PATH/examples/get-started/hello_world $ idf.py set-target esp32 build $ pytest --target esp32带sdkconfig.ci.xxx的单 DUT 用例——以 examples/system/console/basic 为例用 ESP32 测试sdkconfig.ci.history配置$ cd $IDF_PATH/examples/system/console/basic $ idf.py -DSDKCONFIG_DEFAULTSsdkconfig.defaults;sdkconfig.ci.history -B build_esp32_history set-target esp32 build $ pytest --target esp32 -k not nohistory注意这里若使用pytest --target esp32 -k history两个用例都会被选中因为pytest -k采用字符串匹配过滤。若希望一次构建并用所有 sdkconfig 测试可借助 CI 辅助脚本$ cd $IDF_PATH/examples/system/console/basic $ idf-ci build run --target esp32 --only-test-related $ pytest --target esp32sdkconfig.ci.history会在build_esp32_history中构建sdkconfig.ci.nohistory会在build_esp32_nohistory中构建pytest --target esp32会同时运行两个 App 的用例。多 DUT 用例——以 examples/openthread 为例其测试函数形如pytest.mark.parametrize( config, count, app_path, target, [ (rcp|cli_h2|br, 3, f{os.path.join(os.path.dirname(__file__), ot_rcp)} f|{os.path.join(os.path.dirname(__file__), ot_cli)} f|{os.path.join(os.path.dirname(__file__), ot_br)}, esp32c6|esp32h2|esp32s3), ], indirectTrue, ) def test_thread_connect(dut:Tuple[IdfDut, IdfDut, IdfDut]) - None: ...该用例会运行ESP32-C6 烧录ot_rcp、ESP32-H2 烧录ot_cli、ESP32-S3 烧录ot_br。可以手动构建所需二进制也可以借助 CI 脚本$ cd $IDF_PATH/examples/openthread $ idf-ci build run --only-test-related -k test_thread_connect $ pytest -k test_thread_connect重要多 DUT 用例必须列出全部目标否则用例会直接报错。调试 CI 测试用例当本地无法复现 CI 失败时可用--pipeline-id pipeline_id强制 pytest 从 CI 下载二进制$ cd $IDF_PATH/examples/get-started/hello_world $ pytest --target esp32 --pipeline-id 123456即使本地已有build_esp32_default或build目录pytest 仍会从 pipeline 123456 下载二进制并放入build_esp32_default再运行该用例。pipeline_id应为父流水线 ID可从 MR 页面复制。Pytest 技巧与建议自定义 DUT 类当需要为若干 DUT 增加可复用函数、或添加自定义 setup/teardown 时可自定义类。示例改编自 tools/test_apps/system/panic/panic_base/conftest.pyclass PanicTestDut(IdfDut): ... pytest.fixture(scopemodule) def monkeypatch_module(request: FixtureRequest) - MonkeyPatch: mp MonkeyPatch() request.addfinalizer(mp.undo) return mp pytest.fixture(scopemodule, autouseTrue) def replace_dut_class(monkeypatch_module: MonkeyPatch) - None: monkeypatch_module.setattr(pytest_embedded_idf.dut.IdfDut, PanicTestDut)monkeypatch_module提供模块作用域的monkeypatchfixturereplace_dut_class是模块作用域的 autouse fixture负责把默认的IdfDut类替换为自定义类。标记 Flaky 用例基于以太网或 Wi-Fi 的用例常因网络问题不稳定可将特定用例标记为 flaky。示例改编自 components/esp_eth/test_apps/pytest_esp_eth.pypytest.mark.flaky(reruns3, reruns_delay5) def test_esp_eth_ip101(dut: IdfDut) - None: ...该 marker 表示若测试函数失败最多重试 3 次每次间隔 5 秒。标记已知失败当用例因功能缺陷或环境不稳定而持续失败时可用xfail标记并附上易读的原因。示例改编自 tools/test_apps/system/panic/panic_base/pytest_panic.pypytest.mark.xfail(config.getvalue(target) esp32s2, reasonraised IllegalInstruction instead) def test_cache_error(dut: PanicTestDut, config: str, test_func_name: str) - None:该 marker 表示该用例在 ESP32-S2 上是已知失败。标记 Nightly Run 用例部分用例因 runner 资源不足只在夜间流水线中运行pytest.mark.nightly_run该 marker 表示用例只在设置了环境变量NIGHTLY_RUN或INCLUDE_NIGHTLY_RUN时运行。标记 CI 中临时禁用本地能通过、但 CI 中因 runner 不足需临时禁用的用例pytest.mark.temp_skip_ci(targets[esp32, esp32s2], reasonlack of runners)该 marker 表示用例仍可在本地用pytest --target esp32运行但不会在 CI 中执行。添加新 MarkerESP-IDF 目前使用两类自定义 markertarget marker 表示用例支持的目标芯片env marker 表示用例应分配给带对应标签的 CI runner。新增 marker 只需在仓库根目录 pytest.ini 中加一行若它是环境类型 marker加入env_markers段否则加入markers段。语法为marker_name: marker_description。例如pytest.ini中的generic: tests should be run on generic runners与temp_skip_ci: mark test to be skipped in CI分别展示了环境类与通用类 marker 的声明方式。跳过自动烧录调试测试脚本时可跳过每次自动烧录$ pytest --skip-autoflash y记录统计信息需要记录性能等统计信息时可在测试脚本中使用record_xml_attributefixture统计结果会以属性形式写入 JUnit 报告。日志系统可使用 Python 标准logging模块在用例运行期间增加额外日志。ESP-IDF 还提供两个日志相关 fixturelog_performancedef test_hello_world( dut: IdfDut, log_performance: Callable[[str, object], None], ) - None: log_performance(test, 1)上例会以预定义格式[performance][test]: 1记录性能项若指定--junitxml filepath它还会被记录到 JUnit 报告的properties标签下对应的测试节点形如testcase classnameexamples.get-started.hello_world.pytest_hello_world fileexamples/get-started/hello_world/pytest_hello_world.py line13 nameesp32.default.test_hello_world time8.389 properties property nametest value1/ /properties /testcasecheck_performanceESP-IDF 提供了 C 宏TEST_PERFORMANCE_LESS_THAN和TEST_PERFORMANCE_GREATER_THAN来记录性能项并校验其是否在有效范围内。当性能值无法在 C 代码中测量时也可使用等价的 Python 函数。请注意优先推荐使用 C 宏因为 Python 函数无法很好地区分同一性能项在不同#ifdef块下的阈值。def test_hello_world( dut: IdfDut, check_performance: Callable[[str, float, str], None], ) - None: check_performance(RSA_2048KEY_PUBLIC_OP, 123, esp32) check_performance(RSA_2048KEY_PUBLIC_OP, 19001, esp32)上例会先从组件专属的性能头文件例如 ADC 性能测试使用的 components/esp_adc/test_apps/adc/include/adc_performance.h中读取性能项RSA_2048KEY_PUBLIC_OP的阈值再检查取值是否达到下限或超过上限。假设IDF_PERFORMANCE_MAX_RSA_2048KEY_PUBLIC_OP为 19000则第一行check_performance通过第二行会失败并告警[Performance] RSA_2048KEY_PUBLIC_OP value is 19001, doesnt meet pass standard 19000.0。进一步阅读深入了解 pytest 基础用法可阅读仓库根目录的 pytest.inimarker 定义与默认选项以及各 test_apps 目录下的真实 pytest 脚本例如 examples/get-started/hello_world/pytest_hello_world.py 与 components/console/test_apps/console/pytest_console.py需要编写或维护测试 App 时可参考 examples、tools/test_apps 以及各组件下的test_apps目录涉及 CI 构建与任务生成机制时可查阅 tools/ci/dynamic_pipelines 与 .idf_build_apps.toml。【免费下载链接】esp-idfEspressif IoT Development Framework. Official development framework for Espressif SoCs.项目地址: https://gitcode.com/GitHub_Trending/es/esp-idf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考