Coverage for pytest_recap/models.py: 50%

133 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2025-05-05 06:24 -0600

1"""Models for test session data. 

2 

3Core models: 

41. TestOutcome - Enum for test result outcomes 

52. TestResult - Single test execution result 

63. TestSession - Collection of test results with metadata 

74. RerunTestGroup - Group of related test reruns 

8""" 

9 

10import logging 

11from dataclasses import dataclass, field 

12from datetime import datetime, timedelta, timezone 

13from enum import Enum 

14from typing import Any, Dict, List, Optional 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class TestOutcome(Enum): 

20 """ 

21 Test outcome states. 

22 

23 Enum values: 

24 PASSED: Test passed 

25 FAILED: Test failed 

26 SKIPPED: Test skipped 

27 XFAILED: Expected failure 

28 XPASSED: Unexpected pass 

29 RERUN: Test was rerun 

30 ERROR: Test errored 

31 """ 

32 

33 __test__ = False # Tell Pytest this is NOT a test class 

34 

35 PASSED = "PASSED" # Internal representation in UPPERCASE 

36 FAILED = "FAILED" 

37 SKIPPED = "SKIPPED" 

38 XFAILED = "XFAILED" 

39 XPASSED = "XPASSED" 

40 RERUN = "RERUN" 

41 ERROR = "ERROR" 

42 

43 @classmethod 

44 def from_str(cls, outcome: Optional[str]) -> "TestOutcome": 

45 """ 

46 Convert string to TestOutcome, always uppercase internally. 

47 

48 Args: 

49 outcome (Optional[str]): Outcome string. 

50 Returns: 

51 TestOutcome: Corresponding enum value. 

52 """ 

53 if not outcome: 

54 return cls.SKIPPED # Return a default enum value instead of None 

55 try: 

56 return cls[outcome.upper()] 

57 except KeyError: 

58 raise ValueError(f"Invalid test outcome: {outcome}") 

59 

60 def to_str(self) -> str: 

61 """ 

62 Convert TestOutcome to string, always lowercase externally. 

63 

64 Returns: 

65 str: Lowercase outcome string. 

66 """ 

67 return self.value.lower() 

68 

69 @classmethod 

70 def to_list(cls) -> List[str]: 

71 """ 

72 Convert entire TestOutcome enum to a list of possible string values. 

73 

74 Returns: 

75 List[str]: List of lowercase outcome strings. 

76 """ 

77 return [outcome.value.lower() for outcome in cls] 

78 

79 def is_failed(self) -> bool: 

80 """ 

81 Check if the outcome represents a failure. 

82 

83 Returns: 

84 bool: True if outcome is failure or error, else False. 

85 """ 

86 return self in (self.FAILED, self.ERROR) 

87 

88 

89@dataclass 

90class TestResult: 

91 """ 

92 Represents a single test result for an individual test run. 

93 

94 Attributes: 

95 nodeid (str): Unique identifier for the test node. 

96 outcome (TestOutcome): Result outcome. 

97 start_time (Optional[datetime]): Start time of the test. 

98 stop_time (Optional[datetime]): Stop time of the test. 

99 duration (Optional[float]): Duration in seconds. 

100 caplog (str): Captured log output. 

101 capstderr (str): Captured stderr output. 

102 capstdout (str): Captured stdout output. 

103 longreprtext (str): Long representation of failure, if any. 

104 has_warning (bool): Whether the test had a warning. 

105 """ 

106 

107 __test__ = False # Tell Pytest this is NOT a test class 

108 

109 nodeid: str 

110 outcome: TestOutcome 

111 start_time: Optional[datetime] = None 

112 stop_time: Optional[datetime] = None 

113 duration: Optional[float] = None 

114 caplog: str = "" 

115 capstderr: str = "" 

116 capstdout: str = "" 

117 longreprtext: str = "" 

118 has_warning: bool = False 

119 

120 def __post_init__(self): 

121 """ 

122 Validate and process initialization data. 

123 

124 Raises: 

125 ValueError: If neither stop_time nor duration is provided. 

126 """ 

127 # Only compute stop_time if both start_time and duration are present and stop_time is missing 

128 if self.stop_time is None and self.start_time is not None and self.duration is not None: 

129 self.stop_time = self.start_time + timedelta(seconds=self.duration) 

130 # Only compute duration if both start_time and stop_time are present and duration is missing 

131 elif self.duration is None and self.start_time is not None and self.stop_time is not None: 

132 self.duration = (self.stop_time - self.start_time).total_seconds() 

133 

134 def to_dict(self) -> Dict: 

135 """ 

136 Convert test result to a dictionary for JSON serialization. 

137 

138 Returns: 

139 dict: Dictionary representation of the test result. 

140 """ 

141 # Handle both string and enum outcomes for backward compatibility 

142 if not hasattr(self.outcome, "to_str"): 

143 logger.warning( 

144 "Non-enum (probably string outcome detected where TestOutcome enum expected. " 

145 f"nodeid={self.nodeid}, outcome={self.outcome}, type={type(self.outcome)}. " 

146 "For proper session context and query filtering, use TestOutcome enum: " 

147 "outcome=TestOutcome.FAILED instead of outcome='failed'. " 

148 "String outcomes are deprecated and will be removed in a future version." 

149 ) 

150 outcome_str = str(self.outcome).lower() 

151 else: 

152 outcome_str = self.outcome.to_str() 

153 

154 return { 

155 "nodeid": self.nodeid, 

156 "outcome": outcome_str, 

157 "start_time": self.start_time.isoformat() if self.start_time else None, 

158 "stop_time": self.stop_time.isoformat() if self.stop_time else None, 

159 "duration": self.duration, 

160 "caplog": self.caplog, 

161 "capstderr": self.capstderr, 

162 "capstdout": self.capstdout, 

163 "longreprtext": self.longreprtext, 

164 "has_warning": self.has_warning, 

165 } 

166 

167 @classmethod 

168 def from_dict(cls, data: Dict) -> "TestResult": 

169 """Create a TestResult from a dictionary.""" 

170 start_time = data.get("start_time") 

171 if isinstance(start_time, str): 

172 start_time = datetime.fromisoformat(start_time) 

173 

174 stop_time = data.get("stop_time") 

175 if isinstance(stop_time, str): 

176 stop_time = datetime.fromisoformat(stop_time) 

177 

178 return cls( 

179 nodeid=data["nodeid"], 

180 outcome=TestOutcome.from_str(data["outcome"]), 

181 start_time=start_time, 

182 stop_time=stop_time, 

183 duration=data.get("duration"), 

184 caplog=data.get("caplog", ""), 

185 capstderr=data.get("capstderr", ""), 

186 capstdout=data.get("capstdout", ""), 

187 longreprtext=data.get("longreprtext", ""), 

188 has_warning=data.get("has_warning", False), 

189 ) 

190 

191 

192@dataclass 

193class RerunTestGroup: 

194 """ 

195 Groups test results for tests that were rerun, chronologically ordered with final result last. 

196 

197 Attributes: 

198 nodeid (str): Test node ID. 

199 tests (List[TestResult]): List of TestResult objects for each rerun. 

200 """ 

201 

202 __test__ = False 

203 

204 nodeid: str 

205 tests: List[TestResult] = field(default_factory=list) 

206 

207 def add_test(self, result: "TestResult"): 

208 """ 

209 Add a test result and maintain chronological order. 

210 

211 Args: 

212 result (TestResult): TestResult to add. 

213 """ 

214 self.tests.append(result) 

215 self.tests.sort(key=lambda t: t.start_time) 

216 

217 @property 

218 def final_outcome(self): 

219 """ 

220 Get the outcome of the final test (non-RERUN and non-ERROR). 

221 

222 Returns: 

223 Optional[TestOutcome]: Final outcome if available. 

224 """ 

225 outcomes = [t.outcome for t in self.tests] 

226 if TestOutcome.FAILED in outcomes: 

227 return TestOutcome.FAILED 

228 return outcomes[-1] if outcomes else None 

229 

230 def to_dict(self) -> Dict: 

231 """ 

232 Convert to dictionary for JSON serialization. 

233 

234 Returns: 

235 dict: Dictionary representation of the rerun group. 

236 """ 

237 return {"nodeid": self.nodeid, "tests": [t.to_dict() for t in self.tests]} 

238 

239 @classmethod 

240 def from_dict(cls, data: Dict) -> "RerunTestGroup": 

241 """ 

242 Create RerunTestGroup from dictionary. 

243 

244 Args: 

245 data (Dict): Dictionary representation of the rerun group. 

246 Returns: 

247 RerunTestGroup: Instantiated RerunTestGroup object. 

248 """ 

249 if not isinstance(data, dict): 

250 raise ValueError(f"Invalid data for RerunTestGroup. Expected dict, got {type(data)}") 

251 

252 group = cls(nodeid=data["nodeid"]) 

253 

254 tests = [TestResult.from_dict(test_dict) for test_dict in data.get("tests", [])] 

255 group.tests = tests 

256 return group 

257 

258 

259@dataclass 

260class TestSession: 

261 """ 

262 Represents a single test session for a single SUT. 

263 

264 Attributes: 

265 sut_name (str): Name of the system under test. 

266 testing_system (Dict[str, Any]): Metadata about the testing system. 

267 session_id (str): Unique session identifier. 

268 session_start_time (datetime): Start time of the session. 

269 session_stop_time (Optional[datetime]): Stop time of the session. 

270 session_duration (Optional[float]): Duration of the session in seconds. 

271 atta (Dict[str, str]): Arbitrary tags for the session. 

272 rerun_test_groups (List[RerunTestGroup]): Groups of rerun tests. 

273 test_results (List[TestResult]): List of test results in the session. 

274 """ 

275 

276 __test__ = False # Tell Pytest this is NOT a test class 

277 

278 sut_name: str = "" 

279 testing_system: Dict[str, Any] = field(default_factory=dict) 

280 session_id: str = "" 

281 session_start_time: datetime = None 

282 session_stop_time: Optional[datetime] = None 

283 session_duration: Optional[float] = None 

284 session_tags: Dict[str, str] = field(default_factory=dict) 

285 rerun_test_groups: List[RerunTestGroup] = field(default_factory=list) 

286 test_results: List[TestResult] = field(default_factory=list) 

287 

288 def __post_init__(self): 

289 """ 

290 Calculate timing information once at initialization. 

291 """ 

292 # Always set a start time 

293 if self.session_start_time is None: 

294 self.session_start_time = datetime.now(timezone.utc) 

295 # Require at least one of stop_time or duration 

296 if self.session_stop_time is None and self.session_duration is None: 

297 raise ValueError("Either session_stop_time or session_duration must be provided") 

298 if self.session_stop_time is None: 

299 self.session_stop_time = self.session_start_time + timedelta(seconds=self.session_duration) 

300 elif self.session_duration is None: 

301 self.session_duration = (self.session_stop_time - self.session_start_time).total_seconds() 

302 else: 

303 # Both are provided: ignore duration, use stop_time, log a warning 

304 logger.warning( 

305 "Both session_stop_time and session_duration provided. " 

306 "Ignoring session_duration and using session_stop_time as authoritative." 

307 ) 

308 self.session_duration = (self.session_stop_time - self.session_start_time).total_seconds() 

309 

310 def to_dict(self) -> Dict: 

311 """ 

312 Convert TestSession to a dictionary for JSON serialization. 

313 

314 Returns: 

315 dict: Dictionary representation of the test session. 

316 """ 

317 return { 

318 "session_id": self.session_id, 

319 "session_tags": self.session_tags or {}, 

320 "session_start_time": self.session_start_time.isoformat(), 

321 "session_stop_time": self.session_stop_time.isoformat(), 

322 "session_duration": self.session_duration, 

323 "sut_name": self.sut_name, 

324 "testing_system": self.testing_system or {}, 

325 "test_results": [test.to_dict() for test in self.test_results], 

326 "rerun_test_groups": [ 

327 {"nodeid": group.nodeid, "tests": [t.to_dict() for t in group.tests]} 

328 for group in self.rerun_test_groups 

329 ], 

330 } 

331 

332 @classmethod 

333 def from_dict(cls, d): 

334 """Create a TestSession from a dictionary.""" 

335 if not isinstance(d, dict): 

336 raise ValueError(f"Invalid data for TestSession. Expected dict, got {type(d)}") 

337 

338 # Convert datetime strings to datetime objects 

339 session_start_time = d.get("session_start_time") 

340 if isinstance(session_start_time, str): 

341 session_start_time = datetime.fromisoformat(session_start_time) 

342 

343 session_stop_time = d.get("session_stop_time") 

344 if isinstance(session_stop_time, str): 

345 session_stop_time = datetime.fromisoformat(session_stop_time) 

346 

347 test_results = [TestResult.from_dict(tr_dict) for tr_dict in d.get("test_results", [])] 

348 

349 rerun_test_groups = [RerunTestGroup.from_dict(group_dict) for group_dict in d.get("rerun_test_groups", [])] 

350 

351 # Create the TestSession with proper datetime objects 

352 return cls( 

353 sut_name=d.get("sut_name"), 

354 testing_system=d.get("testing_system", {}), 

355 session_id=d.get("session_id"), 

356 session_start_time=session_start_time, 

357 session_stop_time=session_stop_time, 

358 session_duration=d.get("session_duration"), 

359 session_tags=d.get("session_tags", {}), 

360 rerun_test_groups=rerun_test_groups, 

361 test_results=test_results, 

362 ) 

363 

364 def add_test_result(self, result: TestResult) -> None: 

365 """ 

366 Add a test result to this session. 

367 

368 Args: 

369 result (TestResult): TestResult to add. 

370 Raises: 

371 ValueError: If result is not a TestResult instance. 

372 """ 

373 if not isinstance(result, TestResult): 

374 raise ValueError( 

375 f"Invalid test result {result}; must be a TestResult object, nistead was type {type(result)}" 

376 ) 

377 

378 self.test_results.append(result) 

379 

380 def add_rerun_group(self, group: RerunTestGroup) -> None: 

381 """ 

382 Add a rerun test group to this session. 

383 

384 Args: 

385 group (RerunTestGroup): RerunTestGroup to add. 

386 Raises: 

387 ValueError: If group is not a RerunTestGroup instance. 

388 """ 

389 if not isinstance(group, RerunTestGroup): 

390 raise ValueError( 

391 f"Invalid rerun group {group}; must be a RerunTestGroup object, instead was type {type(group)}" 

392 ) 

393 

394 self.rerun_test_groups.append(group)