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
« prev ^ index » next coverage.py v7.4.1, created at 2025-05-05 06:24 -0600
1"""Models for test session data.
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"""
10import logging
11from dataclasses import dataclass, field
12from datetime import datetime, timedelta, timezone
13from enum import Enum
14from typing import Any, Dict, List, Optional
16logger = logging.getLogger(__name__)
19class TestOutcome(Enum):
20 """
21 Test outcome states.
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 """
33 __test__ = False # Tell Pytest this is NOT a test class
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"
43 @classmethod
44 def from_str(cls, outcome: Optional[str]) -> "TestOutcome":
45 """
46 Convert string to TestOutcome, always uppercase internally.
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}")
60 def to_str(self) -> str:
61 """
62 Convert TestOutcome to string, always lowercase externally.
64 Returns:
65 str: Lowercase outcome string.
66 """
67 return self.value.lower()
69 @classmethod
70 def to_list(cls) -> List[str]:
71 """
72 Convert entire TestOutcome enum to a list of possible string values.
74 Returns:
75 List[str]: List of lowercase outcome strings.
76 """
77 return [outcome.value.lower() for outcome in cls]
79 def is_failed(self) -> bool:
80 """
81 Check if the outcome represents a failure.
83 Returns:
84 bool: True if outcome is failure or error, else False.
85 """
86 return self in (self.FAILED, self.ERROR)
89@dataclass
90class TestResult:
91 """
92 Represents a single test result for an individual test run.
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 """
107 __test__ = False # Tell Pytest this is NOT a test class
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
120 def __post_init__(self):
121 """
122 Validate and process initialization data.
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()
134 def to_dict(self) -> Dict:
135 """
136 Convert test result to a dictionary for JSON serialization.
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()
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 }
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)
174 stop_time = data.get("stop_time")
175 if isinstance(stop_time, str):
176 stop_time = datetime.fromisoformat(stop_time)
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 )
192@dataclass
193class RerunTestGroup:
194 """
195 Groups test results for tests that were rerun, chronologically ordered with final result last.
197 Attributes:
198 nodeid (str): Test node ID.
199 tests (List[TestResult]): List of TestResult objects for each rerun.
200 """
202 __test__ = False
204 nodeid: str
205 tests: List[TestResult] = field(default_factory=list)
207 def add_test(self, result: "TestResult"):
208 """
209 Add a test result and maintain chronological order.
211 Args:
212 result (TestResult): TestResult to add.
213 """
214 self.tests.append(result)
215 self.tests.sort(key=lambda t: t.start_time)
217 @property
218 def final_outcome(self):
219 """
220 Get the outcome of the final test (non-RERUN and non-ERROR).
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
230 def to_dict(self) -> Dict:
231 """
232 Convert to dictionary for JSON serialization.
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]}
239 @classmethod
240 def from_dict(cls, data: Dict) -> "RerunTestGroup":
241 """
242 Create RerunTestGroup from dictionary.
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)}")
252 group = cls(nodeid=data["nodeid"])
254 tests = [TestResult.from_dict(test_dict) for test_dict in data.get("tests", [])]
255 group.tests = tests
256 return group
259@dataclass
260class TestSession:
261 """
262 Represents a single test session for a single SUT.
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 """
276 __test__ = False # Tell Pytest this is NOT a test class
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)
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()
310 def to_dict(self) -> Dict:
311 """
312 Convert TestSession to a dictionary for JSON serialization.
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 }
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)}")
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)
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)
347 test_results = [TestResult.from_dict(tr_dict) for tr_dict in d.get("test_results", [])]
349 rerun_test_groups = [RerunTestGroup.from_dict(group_dict) for group_dict in d.get("rerun_test_groups", [])]
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 )
364 def add_test_result(self, result: TestResult) -> None:
365 """
366 Add a test result to this session.
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 )
378 self.test_results.append(result)
380 def add_rerun_group(self, group: RerunTestGroup) -> None:
381 """
382 Add a rerun test group to this session.
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 )
394 self.rerun_test_groups.append(group)