Coverage for src/renaissance/recipes/unit_to_pytest.py: 97%
173 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
1# Copyright (C) 2024-2026 TNO
2#
3# Licensed under the terms specified in the LICENSE file in the root directory
4# of this repository, also available at:
5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE
7"""AI: Recipe that converts unittest-style test files to pytest style."""
9import textwrap
10from collections.abc import Sequence
11from pathlib import Path
13from renaissance.integrations.python.ast.util import convert_function
14from renaissance.recipes.python_refactoring import PythonRefactoring
15from renaissance.syntax_tree import PatternMatch
16from renaissance.syntax_tree.ast_finder import find_semantic_kind
17from renaissance.syntax_tree.match_finder import match_pattern
18from renaissance.syntax_tree.node_protocol import NodeProtocol
19from renaissance.syntax_tree.semantic_kind import SemanticKind
22class UnitToPytest(PythonRefactoring):
23 """AI: Recipe that converts unittest-style test files to pytest style."""
25 def __init__(self, file) -> None:
26 """Hide internal administration in the parent class so that this class you only deals with specific refactors."""
27 super().__init__(file)
28 self.black_list_pattern = "utils_for_test"
29 self.white_list_pattern = "test"
31 def run(self) -> None:
32 """Entry point for converting unittest to pytest."""
33 self.refactor()
35 self.post_processing()
37 def refactor(self) -> None:
38 """AI: Apply file-, class-, and function-level unittest-to-pytest conversions."""
39 # 1: file level changes
40 self.convert_test_class()
41 self.restructure_module()
42 self.replace_stmt("unittest.main()", "pytest.main()")
43 self.replace_stmt("import unittest", "import pytest\nfrom hamcrest import *")
44 self.replace_stmt("from parameterized import parameterized", "import pytest\nfrom hamcrest import *")
45 self.replace_stmt("from unittest import TestCase,$$symbols", "import pytest\nfrom hamcrest import *")
46 self.replace_stmt("from unittest import TestCase", "import pytest\nfrom hamcrest import *")
48 # 2: class level changes
49 self.convert_parameterized_test()
50 self.convert_test_setup()
51 self.commit()
53 # 3: function level changes
55 self.convert_skip_test()
56 self.remove_print()
57 self.convert_plain_assert_same_length()
58 self.commit()
59 self.replace_stmt("assert $stmt, $$msg", "assert_that($stmt, is_(True), $$msg)")
60 self.replace_stmt("self.assertTrue($exp,$$msg)", "assert_that($exp, is_(True), $$msg)")
61 self.replace_stmt("self.assertFalse($exp, $$msg)", "assert_that($exp, is_(False), $$msg)")
63 self.convert_assert("self.assertEqual($exp, $act)", "assert_that($exp, is_($act))")
64 self.convert_assert("self.assertGreaterEqual($exp, $act)", "assert_that($exp, greater_than_or_equal_to($act))")
65 self.convert_assert("self.assertGreater($exp, $act)", "assert_that($exp, greater_than($act))")
66 self.convert_assert("self.assertLesserEqual($exp, $act)", "assert_that($exp, less_than_or_equal_to($act))")
67 self.convert_assert("self.assertLesser($exp, $act)", "assert_that($exp, less_than($act))")
68 self.convert_assert("self.assertMultiLineEqual($act, $exp)", "assert_that($act, is_($exp))")
70 self.replace_stmt("self.assertIn($act, $exp)", "assert_that($exp, contain_string($act))")
71 self.replace_stmt("self.assertIsInstance($act, $exp)", "assert_that($act, is_($exp))")
72 self.replace_stmt("with self.assertRaises($exc): $call()", "assert_that(calling($call), raises($exc))")
74 def post_processing(self) -> None:
75 """AI: Repeatedly simplify assert_that(...) expressions until no further changes occur."""
76 # 4: improve to more concise asserts
77 while self.has_changed():
78 self.commit()
79 self.replace_stmt("assert_that($exp)", "assert_that($exp, is_(True))")
80 self.replace_stmt("assert_that(isinstance($exp, $act))", "assert_that($exp, is_($act))")
81 self.replace_stmt("assert_that(len($exp), $act)", "assert_that($exp, has_length($act))")
82 self.replace_stmt("assert_that(len($exp) >= 1)", "assert_that($exp, is_not(empty()))")
83 self.replace_stmt("assert_that(len($exp) >= 1, is_(True))", "assert_that($exp, is_not(empty()))")
84 self.replace_stmt("assert_that(len($exp) == $length)", "assert_that($exp, has_length($length))")
85 self.replace_stmt("assert_that($exp == $act)", "assert_that($exp, is_($act), $$msg)")
86 self.replace_stmt("assert_that($exp == $act, is_(True), $$msg)", "assert_that($exp, is_($act), $$msg)")
87 self.replace_stmt("assert_that(not $stmt, is_(True), $$msg)", "assert_that($stmt, is_(False) ,$$msg)")
88 self.replace_stmt("assert_that($stmt, is_not(True), $$msg)", "assert_that($stmt, is_(False) ,$$msg)")
89 self.replace_stmt("assert_that(not $stmt)", "assert_that($stmt, is_(False))")
90 self.replace_stmt("assert_that($el in $col, is_(True))", "assert_that($col, contains_exactly($el))")
91 self.replace_stmt("assert_that($exp, has_length(is_($act)))", "assert_that($exp, has_length($act))")
92 self.swap_expected_and_actual()
93 self.replace_stmt("assert_that(not $stmt)", "assert_that($stmt, is_(False))")
94 self.replace_stmt("assert_that($exp.startswith($act))", "assert_that($exp, starts_with($act))")
95 self.remove_duplicate_import("import pytest\nfrom hamcrest import *")
96 self.commit()
98 def convert_test_class(self) -> None:
99 """AI: Rewrite TestCase-derived class headers to drop the unittest base class."""
100 test_main: Sequence[NodeProtocol] = self.pattern_factory.create_statements(
101 "class $klass($test_class):\n $$test_cases\n",
102 ) # type: ignore[assignment]
103 for match in match_pattern(self.root.children, test_main):
104 klass = match["$klass"]
105 test_class = match["$test_class"]
107 if test_class.endswith("TestCase"):
108 # class inherit from TestCase (or unittest.TestCase)
109 if klass.endswith("Test"):
110 # class name ends with Test, rename by move Test to front
111 repl = match.signature.replace(f"{klass}({test_class}):", f"Test{klass[:-4]}:")
112 else:
113 # we assume there are only 2 variant TestExample and ExampleTest
114 repl = match.signature.replace(f"({test_class}):", ":")
116 # repl = f'class {match.expansions["$klass"][0]}:\n{raw(match.expansions["$$test_cases"])}'
117 self.replace(repl, match.nodes, False, False)
119 def convert_test_setup(self) -> None:
120 """AI: Convert a setUp method into a pytest autouse fixture named setup."""
121 setup_function = self.pattern_factory.create_statements("def setUp(self): $$stmts")
122 for match in match_pattern(self.body, setup_function):
123 # add decorator to the setup dunction and convert to snake case
124 repl = f"@pytest.fixture(autouse=True)\n{match.signature}".replace(" setUp(self)", " setup(self)")
125 self.replace(repl, match.nodes, False, False)
127 def convert_assert(self, pattern, replacement) -> None:
128 """AI: Replace calls matching pattern with replacement, swapping expected/actual arguments as needed."""
129 pat = self.pattern_factory.create_statements(pattern)
130 for match in match_pattern(self.root.children, pat):
131 repl = replacement
132 if self.is_swapped(match):
133 exp = match["$act"]
134 act = match["$exp"]
135 else: # original is wrong
136 act = match["$act"]
137 exp = match["$exp"]
138 repl = repl.replace("$exp", exp).replace("$act", act)
139 self.replace(repl, match.nodes, False, False)
141 def is_swapped(self, match: PatternMatch) -> bool:
142 """AI: Return whether $exp and $act appear swapped in the match (i.e. $exp is a literal)."""
143 return match.expansions["$exp"][0].semantic_kind is SemanticKind.LITERAL
145 def convert_parameterized_test(self) -> None:
146 """AI: Convert @parameterized.expand-decorated test functions into @pytest.mark.parametrize."""
147 unittest = self.pattern_factory.create_statements(
148 textwrap.dedent("""
149 @parameterized.expand($$parameters)
150 @$$decorator
151 def $fun($$args, *$$varg):
152 $$stmts
153 """),
154 )
155 for match in match_pattern(self.root.children, unittest):
156 fun = match.nodes[0]
157 args = ", ".join([arg.node.arg for arg in match.expansions["$$args"]])
158 if varg := match.expansions["$$varg"]:
159 args = f"{args}, *{varg[0].signature}"
160 args = args.replace("self, ", "")
161 repl = fun.signature
162 if " def " in repl:
163 repl = repl.replace("@parameterized.expand(", f' @pytest.mark.parametrize("{args}",')
164 repl = repl.replace("@unittest.skip(", "@pytest.mark.skip(")
165 repl = textwrap.dedent(repl)
166 else:
167 repl = repl.replace("@parameterized.expand(", f'@pytest.mark.parametrize("{args}",')
168 repl = repl.replace("@unittest.skip(", "@pytest.mark.skip(")
170 self.replace(repl, fun, False, False)
172 def remove_print(self) -> None:
173 """AI: Remove print(...) statements, or their containing block if it's the only statement."""
174 print_msg = self.pattern_factory.create_statements("print($$msg)") # type: ignore[assignment]
175 for match in match_pattern(self.root.children, print_msg):
176 if len(match.nodes[0].parent.parent.body) == 1:
177 self.remove([match.nodes[0].parent.parent], False, False)
178 else:
179 self.remove(match.nodes, False, False)
181 def convert_plain_assert_same_length(self) -> None:
182 """AI: Replace a manual length-check assert with an assert_that(...) has_length assertion."""
183 pattern: Sequence[NodeProtocol] = self.pattern_factory.create_statements(
184 '$act: int = len($real)\nassert $exp == $act, "$act = " + str($act)',
185 )
186 for match in match_pattern(self.body, pattern):
187 repl = 'assert_that($real, has_length($exp), f"length of $real = {len($real)}")'
188 real = match["$real"]
189 exp = match["$exp" if self.is_swapped(match) else "$act"] # use "$act" when original is wrong
190 repl = repl.replace("$exp", exp).replace("$real", real)
191 self.replace(repl, match.nodes, False, False)
193 def convert_skip_test(self) -> None:
194 """AI: Replace unittest.skip attribute references with pytest.mark.skip."""
195 nodes = find_semantic_kind(self.root, SemanticKind.ATTRIBUTE)
196 for node in nodes:
197 if node.signature == "unittest.skip":
198 self.replace("pytest.mark.skip", node, False, False)
200 def swap_expected_and_actual(self) -> None:
201 """AI: Swap the $exp and $act arguments of assert_that(...) calls when they appear reversed."""
202 pattern: Sequence[NodeProtocol] = self.pattern_factory.create_statements("assert_that($exp, is_($act))") # type: ignore[assignment]
203 for match in match_pattern(self.root.children, pattern):
204 if self.is_swapped(match):
205 repl = "assert_that($act, is_($exp))"
206 act = match["$act"]
207 exp = match["$exp"]
208 repl = repl.replace("$exp", exp).replace("$act", act)
209 self.replace(repl, match.nodes, False, False)
211 def restructure_module(self) -> None:
212 """AI: Move module-level functions into a test class, creating one if none exists."""
213 funs = [stmt for stmt in self.body if stmt.semantic_kind is SemanticKind.FUNCTION]
214 test_classes = [stmt for stmt in self.body if stmt.semantic_kind is SemanticKind.CLASS and stmt.name.startswith("Test")]
215 if len(funs) == 0:
216 return
217 if len(test_classes) == 0:
218 # file does not contain any test class, create a new class and add function in class
219 cls = f"class {self.convert_file_to_test_class()}:\n"
220 for fun in funs:
221 cls += textwrap.indent(convert_function(fun), " ")
222 self.remove([fun])
223 self.insert_before(cls, funs[0])
224 else:
225 # one or more class in file, add function as member of the last class in file
226 for fun in funs:
227 # assuming the class comes first
228 meth = convert_function(fun)
229 self.insert_after(meth, test_classes[-1].body[-1])
230 self.remove(fun)
231 self.commit()
232 for fun in funs:
233 # also change the calling signature of those functions in case they are not test cases
234 function_call = [self.pattern_factory.create_expression(f"{fun.name}($$args)")]
235 for call in match_pattern(self.root.children, function_call):
236 sig = call.nodes[0].signature
237 self.replace(f"self.{sig}", call.nodes, False, False)
238 self.commit()
240 def convert_file_to_test_class(self):
241 """AI: Derive a PascalCase Test-prefixed class name from the file's stem."""
242 path = Path(self.filename)
243 stem = path.stem
244 parts = stem.split("_")
245 if parts[-1].lower() == "test":
246 parts = parts[:-1]
247 name = "".join(word.capitalize() for word in parts)
248 return name if name.startswith("Test") else f"Test{name}"
250 def remove_duplicate_import(self, import_str) -> None:
251 """AI: Remove duplicate occurrences of the given import statement, keeping the first and last."""
252 import_stmt: Sequence[NodeProtocol] = self.pattern_factory.create_statements(import_str) # type: ignore[assignment]
253 # type: ignore[assignment]
254 duplicate_imports = match_pattern(self.body, import_stmt)
256 for match in duplicate_imports[1:-1]:
257 self.remove(match.nodes, False, False)