如何使用 src 和测试文件夹结构为 Python 生成单元测试(最好与 PyCharm 集成)?

How to generate unit tests for Python using src and test folder structure (preferable with PyCharm integration)?

提问人:Stefan 提问时间:11/2/2023 最后编辑:Stefan 更新时间:11/8/2023 访问量:124

问:

在我的 python 项目中,我有以下文件夹结构:

src
  foo
    foo.py
  baa
    baa.py
test
  foo
  baa

并希望生成一个单元测试文件来测试 .test/foo/test_foo.pysrc/foo/foo.py

在 PyCharm 中,我可以

  • 去 foo.py 的某个地方,然后
  • 使用组合键和Ctrl+Shift+T
  • 选择“创建新测试”选项。

这将生成一个新的测试文件,并使用一些测试方法对其进行初始化。另请参阅 https://www.jetbrains.com/help/pycharm/creating-tests.html

然而:

  • PyCharm 不会自动检测所需的目标目录,但建议改用源目录。这将导致测试文件与测试文件位于同一文件夹中。test/foosrc/foo

  • 组合键不适用于不包含类或函数但仅提供属性的文件/模块。

  • PyCharm 会生成不需要的 init.py 文件。

因此,PyCharm 的内置单元测试生成对我不起作用。

=> 为 Python 生成单元测试的推荐方法是什么?

我还尝试使用 UnitTestBotpynguin,但无法生成相应的测试文件。手动创建所有文件夹和文件将是一项繁琐的任务,我希望现代 IDE 能够为我完成(部分)工作。

你可能会争辩说,测试应该先写出来。因此,如果可以选择反其道而行之,并从退出测试中生成测试文件......这也会有所帮助。

另一种选择可能是使用 GitHub Copilot。但是,我不被允许在工作中使用它,也不想将我们的代码发送到远程服务器。因此,我仍在寻找一种更保守的方法。

另一种方法是使用自定义脚本,该脚本遍历现有的 src 文件夹结构,并至少在测试文件夹中创建相应的文件夹和文件。我希望有一个现有的解决方案,但还没有找到一个。

相关:

https://github.com/UnitTestBot/UTBotJava/issues/2670

https://github.com/se2p/pynguin/issues/52

pytest 或 python 中的任何测试工具可以自动生成单元测试吗?

https://www.strictmode.io/articles/using-github-copilot-for-testing

https://dev.to/this-is-learning/copilot-chat-writes-unit-tests-for-you-1c82

python 单元测试 pycharm pytest 代码生成

评论

0赞 Malcolm 11/2/2023
在您站点的文档中,“创建测试”对话框包含一个“目标目录”字段。只需将其设置为您希望测试文件去的位置即可。不?
0赞 Malcolm 11/2/2023
我已经仔细检查过,这有效。
0赞 Malcolm 11/2/2023
我还没有弄清楚的是,为什么“目标目录”不默认为您使用“将目录标记为/源根 - 将目录标记为/测试源根”标识的目录(您必须分两步完成)。
0赞 Malcolm 11/2/2023
不好意思。我误读了这个问题。我看到这实际上是你真正的问题。
0赞 Stefan 11/2/2023
PyCharm 没有标记测试文件夹的选项。也许这是问题的一部分。但是,即使我手动将其标记为测试文件夹,也不会自动识别目标目录。手动为每个新测试文件指定目录会很烦人。intellij-support.jetbrains.com/hc/en-us/community/posts/......

答:

0赞 Stefan 11/8/2023 #1

下面是一个脚本的初稿,该脚本为缺少的测试文件生成单元测试框架。我让 AI 为我生成该脚本并对其进行了重构。

import inspect
import os


def main():
    src_dir = 'src'
    test_dir = 'test'
    generate_unit_test_skeleton(src_dir, test_dir)


def generate_unit_test_skeleton(src_dir, test_dir):
    # Loop over all folders and files in src directory
    for root, dirs, files in os.walk(src_dir):
        # Get the relative path of the current folder or file
        relative_folder_path = os.path.relpath(root, src_dir)

        # Create the corresponding unit test folder in test directory
        test_folder = generate_test_folder_if_not_exists(relative_folder_path, test_dir)

        # Loop over all files in the current folder
        for file in files:
            # Check if the file has a .py extension
            if file.endswith('.py'):
                generate_unit_tests_for_file(
                    file,
                    relative_folder_path,
                    test_folder,
                )


def generate_unit_tests_for_file(
    file,
    relative_directory,
    test_directory,
):
    # Create the corresponding unit test file in test directory
    generated_test_file_path = generate_unit_test_file(
        file,
        relative_directory,
        test_directory,
    )

    if generated_test_file_path is not None:
        # Get all classes and functions defined in the original file
        classes, functions = determine_members(file, relative_directory)

        # Generate test functions for each class and function
        generate_test_functions(
            file,
            generated_test_file_path,
            classes,
            functions,
        )


def generate_test_functions(
    file,
    test_file_path,
    classes,
    functions,
):
    module_name = determine_module_name(file)

    with open(test_file_path, 'a') as test_file:
        for class_name, class_instance in classes:
            generate_test_function_for_class(
                test_file,
                module_name,
                class_name,
                class_instance,
            )

        for function_name, function_instance in functions:
            generate_test_function_for_function(
                test_file,
                module_name,
                function_name,
                function_instance,
            )


def generate_test_function_for_function(
    test_file,
    module_name,
    function_name,
    function_instance,
):
    # Generate the test function name
    test_function_name = f'test_{function_name}'
    arguments = determine_arguments(function_instance)

    # Write the test function to the test file
    test_file.write(f'def {test_function_name}():\n')
    test_file.write(f'    # TODO: Implement test\n')
    test_file.write(f'    # result = {module_name}.{function_name}({arguments})\n')
    test_file.write(f'    pass\n')
    test_file.write('\n')


def generate_test_function_for_class(
    test_file,
    module_name,
    class_name,
    class_instance,
):
    # Generate the test function name
    test_function_name = f'test_{class_name}'

    arguments = determine_arguments(class_instance)

    # Write the test function to the test file
    test_file.write(f'def {test_function_name}():\n')
    test_file.write(f'    # TODO: Implement test\n')
    test_file.write(f'    # instance = {module_name}.{class_name}({arguments})\n')
    test_file.write(f'    pass\n')
    test_file.write('\n')


def determine_members(file, relative_directory):
    # Get the module name
    module_name = os.path.splitext(file)[0]

    # Import the module
    directory_import_path = relative_directory.replace('\\', '.')
    import_path = f'{directory_import_path}.{module_name}'
    module = __import__(import_path, fromlist=[module_name])

    # Get all classes and functions defined in the module
    classes = inspect.getmembers(module, inspect.isclass)
    functions = inspect.getmembers(module, inspect.isfunction)
    return classes, functions


def determine_arguments(function_instance):
    try:
        signature = inspect.signature(function_instance)
    except ValueError:
        return ''

    parameters = signature.parameters

    arguments = []
    for param in parameters.values():
        argument = determine_argument(param)
        arguments.append(argument)

    argument_string = ', '.join(arguments)
    if len(arguments) > 2:
        argument_string += ','  # leading comma causes line breaks if formatted with black
    return argument_string


def determine_argument(param):
    argument = param.name
    if param.default != inspect.Parameter.empty:
        default_value = determine_default_value(param.default)
        argument += f'={default_value}'
    return argument


def determine_default_value(default_instance):
    if inspect.isfunction(default_instance):
        return default_instance.__name__
    elif isinstance(default_instance, str):
        return f"'{default_instance}'"
    else:
        return default_instance


def generate_unit_test_file(file, relative_directory, test_directory):
    test_file_path = os.path.join(test_directory, f'test_{file}')
    if os.path.exists(test_file_path):
        return None
    else:
        # Open the test file in write mode
        with open(test_file_path, 'w') as f:
            # Write the initial import statement
            import_statement = generate_import_statement(file, relative_directory)
            f.write(import_statement)
            f.write('\n')
    return test_file_path


def generate_import_statement(file, relative_directory):
    directory_import_path = relative_directory.replace('////', '.')
    module_name = determine_module_name(file)
    statement = f'from {directory_import_path} import {module_name}\n'
    return statement


def determine_module_name(file):
    name = os.path.splitext(file)[0]
    return name


def generate_test_folder_if_not_exists(relative_path, test_dir):
    test_folder = os.path.join(test_dir, relative_path)
    if not os.path.exists(test_folder):
        os.makedirs(test_folder)
    return test_folder


if __name__ == '__main__':
    main()

文件“test/foo/test_foo.py”的示例结果:

import foo.foo

def test_Language():
    # TODO: Implement test
    # instance = controls.Language(value, names=None, module=None, qualname=None, type=None, start=1, boundary=None,)
    pass

def test_Layout():
    # TODO: Implement test
    # instance = controls.Layout(kwargs)
    pass

def test_SimpleNamespace():
    # TODO: Implement test
    # instance = controls.SimpleNamespace()
    pass