Coverage for src / renaissance / recipes / unit2pytest.py: 97%

172 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-09-09 14:04 +0000

1import textwrap 

2from collections.abc import Sequence 

3from pathlib import Path 

4 

5from renaissance.integrations.python.ast.util import convert_function 

6from renaissance.integrations.types import Attribute, ClassDef, FormattedString, FunctionDef, Literal, Number 

7from renaissance.recipes.python_refactoring import PythonRefactoring 

8from renaissance.syntax_tree import PatternMatch 

9from renaissance.syntax_tree.ast_finder import find_ast_type 

10from renaissance.syntax_tree.match_finder import AstProtocol, match_pattern 

11 

12 

13class Unit2Pytest(PythonRefactoring): 

14 def __init__(self, file): 

15 """Hide internal administration in the parent class so that this class you only deals with specific refactors.""" 

16 super().__init__(file) 

17 self.black_list_pattern = "utils_for_test" 

18 self.white_list_pattern = "test" 

19 

20 def run(self): 

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

22 self.refactor() 

23 

24 self.post_processing() 

25 

26 def refactor(self): 

27 # 1: file level changes 

28 self.convert_test_class() 

29 self.restructure_module() 

30 self.replace_stmt("unittest.main()", "pytest.main()") 

31 self.replace_stmt("import unittest", "import pytest\nfrom hamcrest import *") 

32 self.replace_stmt("from parameterized import parameterized", "import pytest\nfrom hamcrest import *") 

33 self.replace_stmt("from unittest import TestCase,$$symbols", "import pytest\nfrom hamcrest import *") 

34 self.replace_stmt("from unittest import TestCase", "import pytest\nfrom hamcrest import *") 

35 

36 # 2: class level changes 

37 self.convert_parameterized_test() 

38 self.convert_test_setup() 

39 self.commit() 

40 

41 # 3: function level changes 

42 

43 self.convert_skip_test() 

44 self.remove_print() 

45 self.convert_plain_assert_same_length() 

46 self.commit() 

47 self.replace_stmt("assert $stmt, $$msg", "assert_that($stmt, is_(True), $$msg)") 

48 self.replace_stmt("self.assertTrue($exp,$$msg)", "assert_that($exp, is_(True), $$msg)") 

49 self.replace_stmt("self.assertFalse($exp, $$msg)", "assert_that($exp, is_(False), $$msg)") 

50 

51 self.convert_assert("self.assertEqual($exp, $act)", "assert_that($exp, is_($act))") 

52 self.convert_assert("self.assertGreaterEqual($exp, $act)", "assert_that($exp, greater_than_or_equal_to($act))") 

53 self.convert_assert("self.assertGreater($exp, $act)", "assert_that($exp, greater_than($act))") 

54 self.convert_assert("self.assertLesserEqual($exp, $act)", "assert_that($exp, less_than_or_equal_to($act))") 

55 self.convert_assert("self.assertLesser($exp, $act)", "assert_that($exp, less_than($act))") 

56 self.convert_assert("self.assertMultiLineEqual($act, $exp)", "assert_that($act, is_($exp))") 

57 

58 self.replace_stmt("self.assertIn($act, $exp)", "assert_that($exp, contain_string($act))") 

59 self.replace_stmt("self.assertIsInstance($act, $exp)", "assert_that($act, is_($exp))") 

60 self.replace_stmt("with self.assertRaises($exc): $call()", "assert_that(calling($call), raises($exc))") 

61 

62 def post_processing(self): 

63 # 4: improve to more concise asserts 

64 while self.has_changed(): 

65 self.commit() 

66 self.replace_stmt("assert_that($exp)", "assert_that($exp, is_(True))") 

67 self.replace_stmt("assert_that(isinstance($exp, $act))", "assert_that($exp, is_($act))") 

68 self.replace_stmt("assert_that(len($exp), $act)", "assert_that($exp, has_length($act))") 

69 self.replace_stmt("assert_that(len($exp) >= 1)", "assert_that($exp, is_not(empty()))") 

70 self.replace_stmt("assert_that(len($exp) >= 1, is_(True))", "assert_that($exp, is_not(empty()))") 

71 self.replace_stmt("assert_that(len($exp) == $length)", "assert_that($exp, has_length($length))") 

72 self.replace_stmt("assert_that($exp == $act)", "assert_that($exp, is_($act), $$msg)") 

73 self.replace_stmt("assert_that($exp == $act, is_(True), $$msg)", "assert_that($exp, is_($act), $$msg)") 

74 self.replace_stmt("assert_that(not $stmt, is_(True), $$msg)", "assert_that($stmt, is_(False) ,$$msg)") 

75 self.replace_stmt("assert_that($stmt, is_not(True), $$msg)", "assert_that($stmt, is_(False) ,$$msg)") 

76 self.replace_stmt("assert_that(not $stmt)", "assert_that($stmt, is_(False))") 

77 self.replace_stmt("assert_that($el in $col, is_(True))", "assert_that($col, contains_exactly($el))") 

78 self.replace_stmt("assert_that($exp, has_length(is_($act)))", "assert_that($exp, has_length($act))") 

79 self.swap_expected_and_actual() 

80 self.replace_stmt("assert_that(not $stmt)", "assert_that($stmt, is_(False))") 

81 self.replace_stmt("assert_that($exp.startswith($act))", "assert_that($exp, starts_with($act))") 

82 self.remove_duplicate_import("import pytest\nfrom hamcrest import *") 

83 self.commit() 

84 

85 def convert_test_class(self): 

86 test_main: Sequence[AstProtocol] = self.pattern_factory.create_statements( 

87 "class $klass($test_class):\n $$test_cases\n", 

88 ) # type: ignore[assignment] 

89 for match in match_pattern(self.root.children, test_main): 

90 klass = match["$klass"] 

91 test_class = match["$test_class"] 

92 

93 if test_class.endswith("TestCase"): 

94 # class inherit from TestCase (or unittest.TestCase) 

95 if klass.endswith("Test"): 

96 # class name ends with Test, rename by move Test to front 

97 repl = match.signature.replace(f"{klass}({test_class}):", f"Test{klass[:-4]}:") 

98 else: 

99 # we assume there are only 2 variant TestExample and ExampleTest 

100 repl = match.signature.replace(f"({test_class}):", ":") 

101 

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

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

104 

105 def convert_test_setup(self): 

106 setup_function = self.pattern_factory.create_statements("def setUp(self): $$stmts") 

107 for match in match_pattern(self.body, setup_function): 

108 # add decorator to the setup dunction and convert to snake case 

109 repl = f"@pytest.fixture(autouse=True)\n{match.signature}".replace(" setUp(self)", " setup(self)") 

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

111 

112 def convert_assert(self, pattern, replacement): 

113 pat = self.pattern_factory.create_statements(pattern) 

114 for match in match_pattern(self.root.children, pat): 

115 repl = replacement 

116 if self.is_swapped(match): 

117 exp = match["$act"] 

118 act = match["$exp"] 

119 else: # original is wrong 

120 act = match["$act"] 

121 exp = match["$exp"] 

122 repl = repl.replace("$exp", exp).replace("$act", act) 

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

124 

125 def is_swapped(self, match: PatternMatch) -> bool: 

126 return match.expansions["$exp"][0].ast_type in [Literal, FormattedString, Number] 

127 

128 def convert_parameterized_test(self): 

129 unittest = self.pattern_factory.create_statements( 

130 textwrap.dedent(""" 

131 @parameterized.expand($$parameters) 

132 @$$decorator 

133 def $fun($$args, *$$varg): 

134 $$stmts 

135 """), 

136 ) 

137 for match in match_pattern(self.root.children, unittest): 

138 fun = match.nodes[0] 

139 args = ", ".join([arg.node.arg for arg in match.expansions["$$args"]]) 

140 if varg := match.expansions["$$varg"]: 

141 args = f"{args}, *{varg[0].signature}" 

142 args = args.replace("self, ", "") 

143 repl = fun.signature 

144 if " def " in repl: 

145 repl = repl.replace("@parameterized.expand(", f' @pytest.mark.parametrize("{args}",') 

146 repl = repl.replace("@unittest.skip(", "@pytest.mark.skip(") 

147 repl = textwrap.dedent(repl) 

148 else: 

149 repl = repl.replace("@parameterized.expand(", f'@pytest.mark.parametrize("{args}",') 

150 repl = repl.replace("@unittest.skip(", "@pytest.mark.skip(") 

151 

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

153 

154 def remove_print(self): 

155 print_msg = self.pattern_factory.create_statements("print($$msg)") # type: ignore[assignment] 

156 for match in match_pattern(self.root.children, print_msg): 

157 if len(match.nodes[0].parent.parent.body) == 1: 

158 self.remove([match.nodes[0].parent.parent], False, False) 

159 else: 

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

161 

162 def convert_plain_assert_same_length(self): 

163 pattern: Sequence[AstProtocol] = self.pattern_factory.create_statements( 

164 '$act: int = len($real)\nassert $exp == $act, "$act = " + str($act)', 

165 ) 

166 for match in match_pattern(self.body, pattern): 

167 repl = 'assert_that($real, has_length($exp), f"length of $real = {len($real)}")' 

168 real = match["$real"] 

169 exp = match["$exp" if self.is_swapped(match) else "$act"] # use "$act" when original is wrong 

170 repl = repl.replace("$exp", exp).replace("$real", real) 

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

172 

173 def convert_skip_test(self): 

174 nodes = find_ast_type(self.root, Attribute) 

175 for node in nodes: 

176 if node.signature == "unittest.skip": 

177 self.replace("pytest.mark.skip", node, False, False) 

178 

179 def swap_expected_and_actual(self): 

180 pattern: Sequence[AstProtocol] = self.pattern_factory.create_statements("assert_that($exp, is_($act))") # type: ignore[assignment] 

181 for match in match_pattern(self.root.children, pattern): 

182 if self.is_swapped(match): 

183 repl = "assert_that($act, is_($exp))" 

184 act = match["$act"] 

185 exp = match["$exp"] 

186 repl = repl.replace("$exp", exp).replace("$act", act) 

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

188 

189 def restructure_module(self): 

190 funs = [stmt for stmt in self.body if stmt.ast_type == FunctionDef] 

191 test_classes = [stmt for stmt in self.body if stmt.ast_type == ClassDef and stmt.name.startswith("Test")] 

192 if len(funs) == 0: 

193 return 

194 if len(test_classes) == 0: 

195 # file does not contain any test class, create a new class and add function in class 

196 cls = f"class {self.convert_file_to_test_class()}:\n" 

197 for fun in funs: 

198 cls += textwrap.indent(convert_function(fun), " ") 

199 self.remove([fun]) 

200 self.insert_before(cls, funs[0]) 

201 else: 

202 # one or more class in file, add function as member of the last class in file 

203 for fun in funs: 

204 # assuming the class comes first 

205 meth = convert_function(fun) 

206 self.insert_after(meth, test_classes[-1].body[-1]) 

207 self.remove(fun) 

208 self.commit() 

209 for fun in funs: 

210 # also change the calling signature of those functions in case they are not test cases 

211 function_call = [self.pattern_factory.create_expression(f"{fun.name}($$args)")] 

212 for call in match_pattern(self.root.children, function_call): 

213 sig = call.nodes[0].signature 

214 self.replace(f"self.{sig}", call.nodes, False, False) 

215 self.commit() 

216 

217 def convert_file_to_test_class(self): 

218 path = Path(self.filename) 

219 stem = path.stem 

220 parts = stem.split("_") 

221 if parts[-1].lower() == "test": 

222 parts = parts[:-1] 

223 name = "".join(word.capitalize() for word in parts) 

224 return name if name.startswith("Test") else f"Test{name}" 

225 

226 def remove_duplicate_import(self, import_str): 

227 import_stmt: Sequence[AstProtocol] = self.pattern_factory.create_statements(import_str) # type: ignore[assignment] 

228 # type: ignore[assignment] 

229 duplicate_imports = match_pattern(self.body, import_stmt) 

230 

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

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