1. 项目概述:为什么我们需要从零构建测试框架与工具链?

在软件开发的日常里,测试常常被看作一个“必要但繁琐”的环节。很多团队,尤其是初创或快速迭代的项目,测试往往停留在“人肉点点点”或者依赖几个零散的脚本。当项目规模稍微扩大,模块间依赖变得复杂,这种临时性的测试方式就会立刻暴露出它的脆弱性:回归测试耗时巨大、测试用例难以维护、环境依赖混乱、测试报告无法追溯。市面上成熟的测试框架,如 pytest unittest ,功能强大,生态丰富,但它们更像一个“黑盒”。你熟练地调用它的 @pytest.fixture assert 语句,却未必清楚其背后的运行机制、插件系统如何工作,以及如何将它无缝嵌入到你团队独特的持续集成流程中。

这就是“从零实现 Python 测试框架与工具链”这个项目的核心价值所在。它不是一个教你如何使用 pytest 写用例的教程,而是一次深度的“逆向工程”与“再造轮子”的实践。通过亲手构建一个最小化但功能完整的测试框架,并围绕它打造一套包括用例发现、环境管理、报告生成、持续集成在内的工具链,你将彻底理解自动化测试的底层逻辑。这个过程能让你在面对任何测试框架时,都能洞悉其设计哲学;在定制团队专属的测试方案时,能够精准地知道从何处下手。无论你是希望提升技术深度的测试工程师,还是需要为团队搭建质量保障体系的技术负责人,或是想深入理解 Python 元编程和插件机制的开发者,这个实战指南都将提供一条清晰的路径。

2. 核心设计思路:一个测试框架的骨架是什么?

在动手写第一行代码之前,我们必须想清楚,一个测试框架最核心的职责是什么?我认为可以归结为三点: 发现测试、执行测试、报告结果 。我们的设计将围绕这三点展开,并逐步添加现代框架应有的特性。

2.1 核心模块拆解

一个自研的测试框架,其核心模块可以抽象为以下几个部分:

  1. 测试加载器 :负责扫描指定目录或模块,识别出哪些是测试用例。这涉及到 Python 的模块导入机制、文件系统遍历和约定(例如,识别以 test_ 开头的文件或函数)。
  2. 测试运行器 :这是框架的引擎,负责调度和执行被发现的测试用例。它需要处理测试的执行顺序、前置后置条件(setup/teardown)、异常捕获,以及最重要的——测试结果的收集。
  3. 断言系统 :提供一套丰富、可读性高的断言方法,让开发者能够方便地验证测试结果。这是测试代码可读性的关键。
  4. 夹具系统 :这是提升测试代码复用性和维护性的核心。它用于管理测试依赖的资源,如数据库连接、临时文件、模拟对象等,并能根据作用域(函数、类、模块、会话)进行生命周期管理。
  5. 插件系统 :一个框架能否具有生命力,关键在于其扩展性。插件系统允许第三方或用户自定义功能,如自定义报告格式、增加命令行参数、集成其他工具等。
  6. 命令行接口 :提供友好的 CLI,让用户可以方便地指定运行哪些测试、使用哪些参数、输出何种格式的报告。

我们的实现将遵循“由简入繁”的原则,先实现一个能跑通最基本流程的框架,再像搭积木一样,逐步为它添加上述高级特性。

2.2 技术选型与工具链规划

框架本身是核心,但要让其发挥价值,必须有一套趁手的工具链。我们将基于 Python 生态进行选型:

  • 核心语言 :Python 3.8+。充分利用 asyncio type hints dataclasses 等现代特性。
  • 构建与包管理 :使用 poetry hatch 。它们不仅能管理依赖,还能标准化项目的构建、打包和发布流程,比传统的 setup.py 更现代、更省心。
  • 代码质量与风格 :集成 black (代码格式化)、 isort (导入排序)、 flake8 ruff (代码检查)。通过预提交钩子保证代码库的整洁。
  • 持续集成 :使用 GitHub Actions GitLab CI 。实现代码推送后自动运行测试、检查代码风格、甚至自动发布版本。
  • 报告与可视化 :框架内置简洁的终端报告。同时,通过插件机制,可以轻松集成生成 HTML JUnit XML 格式的报告,方便与 Jenkins GitLab 等 CI/CD 平台对接。
  • Mock 与依赖注入 :虽然我们可以自己实现简单的 Mock ,但为了实用,会展示如何集成 unittest.mock 标准库,并探讨依赖注入在测试中的设计模式。

注意:工具链的搭建不是一蹴而就的。建议在框架核心功能稳定后,再逐步引入这些工具,避免过早优化带来的复杂度。

3. 从零开始:实现最简测试框架核心

让我们开始动手。首先创建一个新的项目目录,比如 mini_pytest

3.1 第一步:定义测试用例的契约

任何测试框架都需要一个约定,来告诉框架“我是一个测试用例”。我们采用最常见的约定: test_ 开头的函数就是测试用例 。同时,测试类中以 test_ 开头的方法也是用例。

我们先创建一个核心的 runner.py ,它将是我们的测试运行器。

# mini_pytest/runner.py
import inspect
import sys
from pathlib import Path
from typing import Callable, List, Tuple, Any, Dict
import time

class TestResult:
    """单个测试用例的执行结果"""
    def __init__(self, name: str, status: str, duration: float, error: Exception = None):
        self.name = name
        self.status = status  # 'PASS', 'FAIL', 'ERROR'
        self.duration = duration
        self.error = error

class TestRunner:
    """最简化的测试运行器"""
    
    def __init__(self):
        self.results: List[TestResult] = []
        
    def discover_tests(self, path: str) -> List[Tuple[object, Callable]]:
        """发现指定路径下的所有测试用例"""
        test_cases = []
        path_obj = Path(path)
        
        if path_obj.is_file() and path_obj.suffix == '.py':
            # 如果是单个文件,导入这个模块
            module_name = path_obj.stem
            spec = importlib.util.spec_from_file_location(module_name, path)
            module = importlib.util.module_from_spec(spec)
            sys.modules[module_name] = module
            spec.loader.exec_module(module)
            test_cases.extend(self._find_tests_in_module(module))
        elif path_obj.is_dir():
            # 如果是目录,递归查找所有 test_*.py 文件
            for py_file in path_obj.rglob("test_*.py"):
                # 类似地导入模块并查找用例
                # 此处简化,实际需要处理模块路径转换
                pass
        return test_cases
    
    def _find_tests_in_module(self, module) -> List[Tuple[object, Callable]]:
        """在一个模块对象中查找测试用例"""
        cases = []
        for name, obj in inspect.getmembers(module):
            # 查找测试函数
            if name.startswith('test_') and inspect.isfunction(obj):
                cases.append((module, obj))
            # 查找测试类
            elif inspect.isclass(obj) and (name.startswith('Test') or name.endswith('Test')):
                for method_name, method in inspect.getmembers(obj):
                    if method_name.startswith('test_') and inspect.isfunction(method):
                        cases.append((obj(), method))  # 实例化类
        return cases
    
    def run_test(self, test_case: Tuple[object, Callable]) -> TestResult:
        """执行单个测试用例"""
        test_instance, test_func = test_case
        start_time = time.time()
        test_name = f"{test_func.__module__}.{test_func.__qualname__}"
        
        try:
            # 如果 test_instance 是模块,直接调用函数
            # 如果 test_instance 是类的实例,将实例作为self传入
            if isinstance(test_instance, type(test_func.__self__.__class__)): # 简化判断
                test_func(test_instance)
            else:
                test_func()
            status = 'PASS'
            error = None
        except AssertionError as e:
            status = 'FAIL'
            error = e
        except Exception as e:
            status = 'ERROR'
            error = e
        finally:
            duration = time.time() - start_time
            
        return TestResult(test_name, status, duration, error)
    
    def run(self, path: str = '.'):
        """主运行方法"""
        print(f"Discovering tests in {path}...")
        all_cases = self.discover_tests(path)
        print(f"Found {len(all_cases)} test(s).")
        
        for case in all_cases:
            result = self.run_test(case)
            self.results.append(result)
            status_symbol = {'PASS': '✓', 'FAIL': '✗', 'ERROR': '!'}[result.status]
            print(f"  {status_symbol} {result.name} ({result.duration:.3f}s)")
            if result.error:
                print(f"    {type(result.error).__name__}: {result.error}")
        
        self._print_summary()
    
    def _print_summary(self):
        """打印测试摘要"""
        pass_count = sum(1 for r in self.results if r.status == 'PASS')
        fail_count = sum(1 for r in self.results if r.status == 'FAIL')
        error_count = sum(1 for r in self.results if r.status == 'ERROR')
        
        print(f"\n{'='*50}")
        print(f"Summary: {pass_count} passed, {fail_count} failed, {error_count} errors")
        if fail_count + error_count > 0:
            print("FAILED")
            sys.exit(1)
        else:
            print("PASSED")

这个 TestRunner 已经具备了最核心的功能:发现用例、执行用例、收集结果。虽然简陋,但它是一个完整的起点。你可以创建一个 test_sample.py 文件来试试:

# test_sample.py
def test_addition():
    assert 1 + 1 == 2

def test_failure():
    assert 2 * 2 == 5, "故意失败的测试"

class TestMath:
    def test_subtraction(self):
        assert 5 - 3 == 2

然后在命令行运行 python -m mini_pytest.runner test_sample.py ,就能看到最基本的测试输出。

3.2 第二步:实现断言增强与夹具系统雏形

原生的 assert 语句在失败时信息不够友好。我们来创建一个 assertions.py 模块,提供更丰富的断言。

# mini_pytest/assertions.py
class AssertionError(AssertionError):
    """自定义断言错误,便于携带更多上下文信息"""
    pass

def assert_equal(actual, expected, msg=None):
    if actual != expected:
        default_msg = f"AssertionError: {actual!r} != {expected!r}"
        raise AssertionError(msg or default_msg)

def assert_true(expr, msg=None):
    if not expr:
        raise AssertionError(msg or f"AssertionError: {expr!r} is not true")

# 可以继续添加 assert_in, assert_is_instance, assert_raises 等

接下来是夹具系统,这是框架的“进阶”功能。我们将实现一个基于装饰器的简易夹具。

# mini_pytest/fixtures.py
import functools
from typing import Any, Callable, Dict

_FIXTURE_REGISTRY: Dict[str, Callable] = {}

def fixture(func=None, *, scope="function"):
    """夹具装饰器"""
    if func is None:
        return lambda f: fixture(f, scope=scope)
    
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # 简易实现:每次调用都执行函数
        # 实际应根据scope(function, class, module, session)缓存结果
        return func(*args, **kwargs)
    
    _FIXTURE_REGISTRY[func.__name__] = wrapper
    return wrapper

def get_fixture(name: str) -> Any:
    """按名称获取夹具"""
    if name not in _FIXTURE_REGISTRY:
        raise KeyError(f"Fixture '{name}' not found")
    return _FIXTURE_REGISTRY[name]()

然后在运行器中,我们需要修改测试函数的调用逻辑,能够解析函数参数,并自动注入同名的夹具。这涉及到 inspect.signature 的使用。

# 在 runner.py 的 run_test 方法中,调用 test_func 之前
def run_test(self, test_case):
    # ... 之前的代码 ...
    try:
        sig = inspect.signature(test_func)
        params = {}
        for param_name in sig.parameters:
            if param_name in _FIXTURE_REGISTRY: # 假设能访问到夹具注册表
                params[param_name] = get_fixture(param_name)
        
        # 调用时传入解析出的参数
        if isinstance(test_instance, ...):
            test_func(test_instance, **params)
        else:
            test_func(**params)
    # ... 异常处理 ...

现在,测试用例就可以使用夹具了:

# test_with_fixture.py
from mini_pytest.fixtures import fixture

@fixture
def database_connection():
    print("\n(Establishing DB connection...)")
    yield "mock_connection"  # 支持 yield 实现 teardown
    print("(Closing DB connection...)\n")

def test_query_user(database_connection):
    assert database_connection == "mock_connection"
    # 执行查询断言...

实操心得:实现夹具系统时,最复杂的部分是 作用域管理和依赖解析 。例如,一个 session 作用域的夹具,在整个测试会话中只应创建一次。而一个夹具可能又依赖于另一个夹具。这本质上是一个依赖注入容器的实现问题。初期可以只实现 function 作用域,避免过度设计。

4. 构建现代化工具链:让框架真正可用

一个光秃秃的框架是缺乏生产力的。接下来,我们围绕它构建一套提升开发体验和工程效率的工具链。

4.1 使用 Poetry 管理项目与依赖

在项目根目录执行 poetry init ,按照提示创建 pyproject.toml 。这是现代 Python 项目的核心配置文件。

# pyproject.toml
[tool.poetry]
name = "mini-pytest"
version = "0.1.0"
description = "A minimal pytest-like test framework for learning."
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.8"
colorama = "^0.4.6" # 用于终端彩色输出

[tool.poetry.group.dev.dependencies]
black = "^23.0"
isort = "^5.12"
flake8 = "^6.0"
mypy = "^1.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

使用 poetry install 安装所有依赖。 poetry add package poetry add --group dev package 可以分别添加生产环境和开发环境依赖。这解决了虚拟环境和依赖锁定的问题。

4.2 打造友好的命令行接口

我们使用 Python 标准库 argparse 来创建一个更专业的 CLI。

# mini_pytest/cli.py
import argparse
from .runner import TestRunner

def main():
    parser = argparse.ArgumentParser(description="MiniPyTest - A simple test runner")
    parser.add_argument("path", nargs="?", default=".", help="Test directory or file (default: current dir)")
    parser.add_argument("-v", "--verbose", action="store_true", help="Increase output verbosity")
    parser.add_argument("--tb", choices=["short", "long", "no"], default="short", help="Traceback style on failure")
    
    args = parser.parse_args()
    
    runner = TestRunner()
    # 可以将 args 传递给 runner,控制其行为
    runner.run(args.path)

if __name__ == "__main__":
    main()

然后在 pyproject.toml 中配置入口点,让我们的框架可以通过 mpytest 命令直接调用。

# pyproject.toml 追加
[tool.poetry.scripts]
mpytest = "mini_pytest.cli:main"

安装包后 ( poetry install pip install -e . ),就可以在终端使用 mpytest 命令了。

4.3 集成代码质量工具

我们使用 pre-commit 钩子在提交代码前自动运行代码检查和格式化。

首先安装 pre-commit : poetry add --group dev pre-commit 。 然后在项目根目录创建 .pre-commit-config.yaml

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 23.0.0
    hooks:
      - id: black
        language_version: python3
  - repo: https://github.com/pycqa/isort
    rev: 5.12.0
    hooks:
      - id: isort
        args: ["--profile", "black"]
  - repo: https://github.com/pycqa/flake8
    rev: 6.0.0
    hooks:
      - id: flake8
        args: ["--max-line-length=88", "--extend-ignore=E203,W503"]

运行 pre-commit install 安装钩子。此后每次 git commit ,都会自动格式化代码,确保团队风格统一。

4.4 搭建持续集成流水线

以 GitHub Actions 为例,在 .github/workflows/test.yml 中定义 CI 流程:

name: Test and Lint

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.8", "3.9", "3.10", "3.11"]
    steps:
    - uses: actions/checkout@v3
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install Poetry
      run: pipx install poetry
    - name: Install dependencies
      run: poetry install --with dev
    - name: Lint with flake8
      run: poetry run flake8 mini_pytest tests --count --show-source --statistics
    - name: Run tests with self
      run: poetry run mpytest tests/  # 用我们自己的框架跑测试
    - name: Run tests with pytest (optional, for compatibility check)
      run: poetry run pytest tests/ --tb=short

这个工作流会在多个 Python 版本下,安装依赖,进行代码检查,并用我们自研的框架以及标准的 pytest 来运行测试用例。这既验证了我们框架的可用性,也保证了其与主流生态的兼容性。

5. 高级特性实现与插件系统设计

当核心框架和基础工具链就绪后,我们可以考虑添加一些让框架更强大、更实用的高级特性。

5.1 参数化测试的实现

参数化测试允许你用不同的数据运行同一个测试逻辑,极大减少了重复代码。我们可以通过装饰器来实现。

# mini_pytest/params.py
import functools

def parametrize(argnames, argvalues):
    """参数化装饰器"""
    def decorator(test_func):
        # 将参数名拆分为列表
        argname_list = [name.strip() for name in argnames.split(',')]
        
        @functools.wraps(test_func)
        def wrapped(*args, **kwargs):
            # 这个函数本身不执行测试,它只是标记
            # 真正的展开逻辑在运行器中
            return test_func(*args, **kwargs)
        
        # 将参数信息附着在函数上,供运行器读取
        wrapped._parametrize = {
            'argnames': argname_list,
            'argvalues': argvalues
        }
        return wrapped
    return decorator

运行器在发现测试时,需要检查函数是否有 _parametrize 属性。如果有,就需要为每一组参数值动态生成一个子测试函数,并修改其名称(例如 test_func[param1] ),然后将这些生成的函数作为独立的测试用例加入执行队列。这涉及到动态创建函数( types.FunctionType )或使用 functools.partial 的技术。

5.2 插件系统架构

插件系统是框架生态的基石。一个典型的设计是使用“钩子函数”。框架在生命周期的关键节点(如测试收集开始、测试用例执行前、执行后、会话结束等)调用已注册的钩子函数。

  1. 定义钩子规范 :首先,我们定义一个插件管理器和一个钩子装饰器。

    # mini_pytest/plugin.py
    from typing import Dict, List, Callable, Any
    import functools
    
    class PluginManager:
        _hooks: Dict[str, List[Callable]] = {}
        
        @classmethod
        def register(cls, hook_name: str):
            def decorator(func):
                if hook_name not in cls._hooks:
                    cls._hooks[hook_name] = []
                cls._hooks[hook_name].append(func)
                return func
            return decorator
        
        @classmethod
        def call(cls, hook_name: str, **kwargs) -> List[Any]:
            results = []
            for hook_func in cls._hooks.get(hook_name, []):
                results.append(hook_func(**kwargs))
            return results
    
  2. 在框架中植入钩子调用点 :在运行器的关键位置调用钩子。

    # 在 runner.py 的 run 方法中
    def run(self, path: str = '.'):
        # 钩子:pytest_collection_start
        PluginManager.call('pytest_collection_start', items=[])
        
        all_cases = self.discover_tests(path)
        
        # 钩子:pytest_collection_modifyitems,可以修改收集到的用例
        modified_items = PluginManager.call('pytest_collection_modifyitems', items=all_cases)
        if modified_items:
            all_cases = modified_items[0] # 简单处理,取第一个插件的结果
        
        for case in all_cases:
            # 钩子:pytest_runtest_logstart
            PluginManager.call('pytest_runtest_logstart', item=case)
            result = self.run_test(case)
            # 钩子:pytest_runtest_logfinish
            PluginManager.call('pytest_runtest_logfinish', item=case, result=result)
            self.results.append(result)
        
        # 钩子:pytest_sessionfinish
        PluginManager.call('pytest_sessionfinish', session=self, exitstatus=0)
    
  3. 编写一个插件示例 :比如一个生成简单 HTML 报告的插件。

    # plugins/html_report.py
    from mini_pytest.plugin import PluginManager
    from datetime import datetime
    
    @PluginManager.register('pytest_sessionfinish')
    def generate_html_report(session, exitstatus, **kwargs):
        html_content = f"""
        <html><body>
        <h1>Test Report</h1>
        <p>Generated at: {datetime.now()}</p>
        <p>Total: {len(session.results)}</p>
        <ul>
        """
        for r in session.results:
            color = 'green' if r.status == 'PASS' else 'red'
            html_content += f'<li style="color:{color}">{r.name} - {r.status} ({r.duration:.2f}s)</li>'
        html_content += "</ul></body></html>"
        
        with open('test_report.html', 'w') as f:
            f.write(html_content)
        print("HTML report generated: test_report.html")
    

用户只需要安装这个插件包,或者将插件模块放到特定目录并被框架自动发现,插件功能就会生效。这实现了框架核心与扩展功能的解耦。

注意事项:插件系统的设计要特别注意 执行顺序 数据传递 。有些钩子可能需要返回值来影响框架行为(如修改测试项),有些则只是通知。可以参考 pytest 的 hookspec 规范来设计更健壮的系统。

6. 实战中的常见问题与排查技巧

在开发和推广自研框架的过程中,你会遇到各种各样的问题。这里记录一些典型场景和解决思路。

6.1 测试发现失败或遗漏

  • 问题 :运行 mpytest 时,明明有 test_*.py 文件,却提示 Found 0 test(s)
  • 排查
    1. 路径问题 :检查传递给 discover_tests 的路径是否正确,是相对路径还是绝对路径。使用 Path.resolve() 转换为绝对路径试试。
    2. 模块导入失败 :我们的简易发现器使用了 importlib 动态导入。如果测试文件有语法错误,或者它依赖的模块不在 sys.path 中,导入会静默失败。可以在 exec_module 前后添加打印,或捕获 ImportError 并给出友好提示。
    3. 命名约定不符 :检查你的测试函数是否真的以 test_ 开头,或者测试类是否以 Test 开头/结尾。我们的发现逻辑可能比 pytest 严格。
  • 技巧 :在 _find_tests_in_module 函数里添加详细的调试日志,打印出扫描到的所有成员名和类型,这是定位发现逻辑问题最快的方法。

6.2 夹具依赖注入异常

  • 问题 :测试函数定义了参数 database_connection ,但运行时提示 TypeError: test_query_user() missing 1 required positional argument: 'database_connection'
  • 排查
    1. 夹具未注册 :确保 @fixture 装饰器正确应用,并且夹具函数被成功注册到全局的 _FIXTURE_REGISTRY 中。检查装饰器代码的逻辑,特别是当夹具函数有参数时。
    2. 参数名不匹配 :测试函数的参数名必须与夹具函数名完全一致。注意大小写。
    3. 作用域冲突 :如果你实现了作用域缓存,检查是否为同一个作用域创建了多个实例。确保缓存逻辑正确,特别是在多线程环境下。
  • 技巧 :在 get_fixture 函数和参数解析逻辑中加入详细的日志,打印出当前请求的夹具名、已注册的夹具列表、以及最终解析出的参数字典。

6.3 与第三方库的兼容性问题

  • 问题 :项目使用了 pytest 的特定插件(如 pytest-asyncio , pytest-django ),切换到自研框架后无法工作。
  • 解决思路
    1. 明确边界 :自研框架的主要目的是学习和深度定制,并非完全替代 pytest 。对于重度依赖特定生态的项目,可以混合使用,或者只将自研框架用于新模块或特定类型的测试。
    2. 实现关键接口 :如果某些插件非常核心(如异步支持),可以尝试在自己的框架中实现类似的钩子或接口。例如,可以借鉴 pytest-asyncio ,在运行器层面识别 async def 测试函数,并用 asyncio.run() 来执行它。
    3. 包装适配器 :为一些常用的 pytest 功能(如 caplog , tmpdir )编写适配器,让它们能在你的框架中工作。这虽然工作量不小,但是对框架设计很好的锻炼。

6.4 性能问题:当测试用例成千上万时

  • 问题 :测试套件非常庞大,发现和执行变得很慢。
  • 优化方向
    1. 并行测试 :这是最大的性能提升点。可以使用 multiprocessing 模块实现。运行器可以将发现的测试用例分发给多个工作进程执行,然后汇总结果。关键在于进程间通信和状态共享(如夹具的会话作用域)的设计。
    2. 增量发现与执行 :只运行上次失败或修改过的测试。这需要框架能够记录测试结果的历史状态,并与版本控制系统(如 Git)集成,计算出文件变更。
    3. 优化导入 :动态导入每个测试文件是有开销的。可以考虑使用模块缓存,或者像 pytest 那样实现一套自己的导入机制,避免重复的导入开销。
    4. 懒加载夹具 :只有当测试函数真正请求时,才创建夹具实例,而不是在会话开始时就创建所有夹具。

个人体会:从零构建一个测试框架,最大的收获不是造出了一个多厉害的工具,而是在这个过程中,你被迫去思考那些在使用成熟框架时被视为理所当然的问题:测试如何被组织、如何被调度、失败如何被报告、扩展性如何保障。每一个你遇到的“坑”,都是对软件设计理解的一次深化。当你再回头使用 pytest 时,你看它的每一个功能,都能大概猜到它是怎么实现的,这种“透视”能力,是单纯作为使用者无法获得的。最终,这个自研的框架可能不会完全替代 pytest ,但它会成为你团队内部一个强大的、高度定制化的测试工具的基础,或者是你理解自动化测试精髓的最佳凭证。

更多推荐