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

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 

6 

7"""AI: Recipe that converts unittest-style test files to pytest style.""" 

8 

9import textwrap 

10from collections.abc import Sequence 

11from pathlib import Path 

12 

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 

20 

21 

22class UnitToPytest(PythonRefactoring): 

23 """AI: Recipe that converts unittest-style test files to pytest style.""" 

24 

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" 

30 

31 def run(self) -> None: 

32 """Entry point for converting unittest to pytest.""" 

33 self.refactor() 

34 

35 self.post_processing() 

36 

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 *") 

47 

48 # 2: class level changes 

49 self.convert_parameterized_test() 

50 self.convert_test_setup() 

51 self.commit() 

52 

53 # 3: function level changes 

54 

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)") 

62 

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))") 

69 

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))") 

73 

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() 

97 

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"] 

106 

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}):", ":") 

115 

116 # repl = f'class {match.expansions["$klass"][0]}:\n{raw(match.expansions["$$test_cases"])}' 

117 self.replace(repl, match.nodes, False, False) 

118 

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) 

126 

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) 

140 

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 

144 

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(") 

169 

170 self.replace(repl, fun, False, False) 

171 

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) 

180 

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) 

192 

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) 

199 

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) 

210 

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() 

239 

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}" 

249 

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) 

255 

256 for match in duplicate_imports[1:-1]: 

257 self.remove(match.nodes, False, False)