In my python project I have following folder structure:
src
foo
foo.py
baa
baa.py
test
foo
baa
and would like to generate a unit test file test/foo/test_foo.py
that tests src/foo/foo.py
.
In PyCharm I can
Ctrl+Shift+T
andThat generates a new test file and initializes it with some test method. Also see https://www.jetbrains.com/help/pycharm/creating-tests.html
However:
PyCharm does not automatically detect the wanted target directory test/foo
but suggests to use the source directory src/foo
instead. That would result in a test file located in the same folder as the tested file.
The key combination does not work for files/modules that do not contain a class or function but just provide a property.
PyCharm generates unwanted init.py files.
Therefore the built in unit test generation of PyCharm does not work for me.
=> What is the recommended way to generate unit tests for Python?
I also tried to use UnitTestBot or pynguin but was not able to generate corresponding test files. Creating all the folders and files manually would be a tedious task and I hoped that a modern IDE would do (parts of) the job for me.
You might argue that test should be written first. Therefore, if there is an option to do it the other way around and generate the tested files from exiting tests... that would also be helpful.
Another option might be to go for GitHub Copilot. However, I am not allowed to use it at work and do not want to send our code to a remote server. Therefore, I am still looking for a more conservative approach.
A different approach would be a custom script that loops through the existing src folder structure and at least creates corresponding folders and files in the test folder. I was hoping for an existing solution but could not find one, yet.
Related:
https://github.com/UnitTestBot/UTBotJava/issues/2670
https://github.com/se2p/pynguin/issues/52
Can pytest or any testing tool in python generate the unit test automatically?
https://www.strictmode.io/articles/using-github-copilot-for-testing
https://dev.to/this-is-learning/copilot-chat-writes-unit-tests-for-you-1c82
Below is a first draft for a script that generates a unit test skeleton for missing test files. I asked AI to generate that script for me and refactored it a bit.
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()
Example result for a file '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