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
« 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
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
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"
20 def run(self):
21 """Entry point for converting unittest to pytest."""
22 self.refactor()
24 self.post_processing()
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 *")
36 # 2: class level changes
37 self.convert_parameterized_test()
38 self.convert_test_setup()
39 self.commit()
41 # 3: function level changes
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)")
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))")
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))")
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()
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"]
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}):", ":")
102 # repl = f'class {match.expansions["$klass"][0]}:\n{raw(match.expansions["$$test_cases"])}'
103 self.replace(repl, match.nodes, False, False)
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)
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)
125 def is_swapped(self, match: PatternMatch) -> bool:
126 return match.expansions["$exp"][0].ast_type in [Literal, FormattedString, Number]
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(")
152 self.replace(repl, fun, False, False)
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)
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)
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)
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)
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()
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}"
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)
231 for match in duplicate_imports[1:-1]:
232 self.remove(match.nodes, False, False)