rustest 0.1.0__cp312-cp312-macosx_11_0_arm64.whl → 0.3.0__cp312-cp312-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rustest/__init__.py CHANGED
@@ -2,23 +2,27 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from . import _decorators
6
- from ._cli import main
7
- from ._reporting import RunReport, TestResult
5
+ from . import decorators
6
+ from .approx import approx
7
+ from .cli import main
8
+ from .reporting import RunReport, TestResult
8
9
  from .core import run
9
10
 
10
- fixture = _decorators.fixture
11
- mark = _decorators.mark
12
- parametrize = _decorators.parametrize
13
- skip = _decorators.skip
11
+ fixture = decorators.fixture
12
+ mark = decorators.mark
13
+ parametrize = decorators.parametrize
14
+ raises = decorators.raises
15
+ skip = decorators.skip
14
16
 
15
17
  __all__ = [
16
18
  "RunReport",
17
19
  "TestResult",
20
+ "approx",
18
21
  "fixture",
19
22
  "main",
20
23
  "mark",
21
24
  "parametrize",
25
+ "raises",
22
26
  "run",
23
27
  "skip",
24
28
  ]
rustest/__main__.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import sys
6
6
 
7
- from ._cli import main
7
+ from .cli import main
8
8
 
9
9
  if __name__ == "__main__":
10
10
  sys.exit(main())
rustest/approx.py ADDED
@@ -0,0 +1,160 @@
1
+ """Approximate comparison for floating-point numbers.
2
+
3
+ This module provides the `approx` class for comparing floating-point numbers
4
+ with a tolerance, similar to pytest.approx.
5
+ """
6
+
7
+ from typing import Any, Mapping, Sequence, Union
8
+
9
+ # Type alias for values that can be approximated
10
+ ApproxValue = Union[float, int, complex, Sequence[Any], Mapping[str, Any]]
11
+
12
+
13
+ class approx:
14
+ """Assert that two numbers (or collections of numbers) are equal to each other
15
+ within some tolerance.
16
+
17
+ This is similar to pytest.approx and is useful for comparing floating-point
18
+ numbers that may have small rounding errors.
19
+
20
+ Usage:
21
+ assert 0.1 + 0.2 == approx(0.3)
22
+ assert 0.1 + 0.2 == approx(0.3, rel=1e-6)
23
+ assert 0.1 + 0.2 == approx(0.3, abs=1e-9)
24
+ assert [0.1 + 0.2, 0.3] == approx([0.3, 0.3])
25
+ assert {"a": 0.1 + 0.2} == approx({"a": 0.3})
26
+
27
+ Args:
28
+ expected: The expected value to compare against
29
+ rel: The relative tolerance (default: 1e-6)
30
+ abs: The absolute tolerance (default: 1e-12)
31
+
32
+ By default, numbers are considered close if the difference between them is
33
+ less than or equal to:
34
+ abs(expected * rel) + abs_tolerance
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ expected: ApproxValue,
40
+ rel: float = 1e-6,
41
+ abs: float = 1e-12,
42
+ ) -> None:
43
+ """Initialize approx with expected value and tolerances.
44
+
45
+ Args:
46
+ expected: The expected value to compare against
47
+ rel: The relative tolerance (default: 1e-6)
48
+ abs: The absolute tolerance (default: 1e-12)
49
+ """
50
+ super().__init__()
51
+ self.expected = expected
52
+ self.rel = rel
53
+ self.abs = abs
54
+
55
+ def __repr__(self) -> str:
56
+ """Return a string representation of the approx object."""
57
+ return f"approx({self.expected!r}, rel={self.rel}, abs={self.abs})"
58
+
59
+ def __eq__(self, actual: Any) -> bool:
60
+ """Compare actual value with expected value within tolerance.
61
+
62
+ Args:
63
+ actual: The actual value to compare
64
+
65
+ Returns:
66
+ True if the values are approximately equal, False otherwise
67
+ """
68
+ return self._approx_compare(actual, self.expected)
69
+
70
+ def _approx_compare(self, actual: Any, expected: Any) -> bool:
71
+ """Recursively compare actual and expected values.
72
+
73
+ Args:
74
+ actual: The actual value
75
+ expected: The expected value
76
+
77
+ Returns:
78
+ True if values are approximately equal, False otherwise
79
+ """
80
+ # Handle None
81
+ if actual is None or expected is None:
82
+ return actual == expected
83
+
84
+ # Handle dictionaries
85
+ if isinstance(expected, dict):
86
+ if not isinstance(actual, dict):
87
+ return False
88
+ if set(actual.keys()) != set(expected.keys()):
89
+ return False
90
+ return all(self._approx_compare(actual[k], expected[k]) for k in expected.keys())
91
+
92
+ # Handle sequences (lists, tuples, etc.) but not strings
93
+ if isinstance(expected, (list, tuple)) and not isinstance(expected, str):
94
+ # Check that actual is the same type (list vs tuple matters)
95
+ if type(actual) is not type(expected):
96
+ return False
97
+ if len(actual) != len(expected):
98
+ return False
99
+ return all(self._approx_compare(a, e) for a, e in zip(actual, expected))
100
+
101
+ # Handle numbers (float, int, complex)
102
+ if isinstance(expected, (float, int, complex)) and isinstance(
103
+ actual, (float, int, complex)
104
+ ):
105
+ return self._is_close(actual, expected)
106
+
107
+ # For other types, use exact equality
108
+ return actual == expected
109
+
110
+ def _is_close(
111
+ self, actual: Union[float, int, complex], expected: Union[float, int, complex]
112
+ ) -> bool:
113
+ """Check if two numbers are close within tolerance.
114
+
115
+ Uses the formula: |actual - expected| <= max(rel * max(|actual|, |expected|), abs)
116
+
117
+ Args:
118
+ actual: The actual number
119
+ expected: The expected number
120
+
121
+ Returns:
122
+ True if numbers are close, False otherwise
123
+ """
124
+ # Handle infinities and NaN
125
+ if isinstance(actual, complex) or isinstance(expected, complex):
126
+ # For complex numbers, compare real and imaginary parts separately
127
+ if isinstance(actual, complex) and isinstance(expected, complex):
128
+ return self._is_close(actual.real, expected.real) and self._is_close(
129
+ actual.imag, expected.imag
130
+ )
131
+ # One is complex, the other is not
132
+ if isinstance(actual, complex):
133
+ return self._is_close(actual.real, expected) and abs(actual.imag) <= self.abs
134
+ else: # expected is complex
135
+ return self._is_close(actual, expected.real) and abs(expected.imag) <= self.abs
136
+
137
+ # Convert to float for comparison
138
+ actual_float = float(actual)
139
+ expected_float = float(expected)
140
+
141
+ # Handle special float values
142
+ if actual_float == expected_float:
143
+ # This handles infinities and zeros
144
+ return True
145
+
146
+ # Check for NaN - NaN should never be equal to anything
147
+ import math
148
+
149
+ if math.isnan(actual_float) or math.isnan(expected_float):
150
+ return False
151
+
152
+ # Check for infinities
153
+ if math.isinf(actual_float) or math.isinf(expected_float):
154
+ return actual_float == expected_float
155
+
156
+ # Calculate tolerance
157
+ abs_diff = abs(actual_float - expected_float)
158
+ tolerance = max(self.rel * max(abs(actual_float), abs(expected_float)), self.abs)
159
+
160
+ return abs_diff <= tolerance
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  from collections.abc import Sequence
7
7
 
8
- from ._reporting import RunReport, TestResult
8
+ from .reporting import RunReport, TestResult
9
9
  from .core import run
10
10
 
11
11
 
rustest/core.py CHANGED
@@ -4,8 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Sequence
6
6
 
7
- from . import _rust
8
- from ._reporting import RunReport
7
+ from . import rust
8
+ from .reporting import RunReport
9
9
 
10
10
 
11
11
  def run(
@@ -17,5 +17,5 @@ def run(
17
17
  ) -> RunReport:
18
18
  """Execute tests and return a rich report."""
19
19
 
20
- raw_report = _rust.run(list(paths), pattern, workers, capture_output)
20
+ raw_report = rust.run(list(paths), pattern, workers, capture_output)
21
21
  return RunReport.from_py(raw_report)
@@ -192,3 +192,159 @@ class MarkGenerator:
192
192
 
193
193
  # Create a singleton instance
194
194
  mark = MarkGenerator()
195
+
196
+
197
+ class ExceptionInfo:
198
+ """Information about an exception caught by raises().
199
+
200
+ Attributes:
201
+ type: The exception type
202
+ value: The exception instance
203
+ traceback: The exception traceback
204
+ """
205
+
206
+ def __init__(
207
+ self, exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any
208
+ ) -> None:
209
+ super().__init__()
210
+ self.type = exc_type
211
+ self.value = exc_value
212
+ self.traceback = exc_tb
213
+
214
+ def __repr__(self) -> str:
215
+ return f"<ExceptionInfo {self.type.__name__}({self.value!r})>"
216
+
217
+
218
+ class RaisesContext:
219
+ """Context manager for asserting that code raises a specific exception.
220
+
221
+ This mimics pytest.raises() behavior, supporting:
222
+ - Single or tuple of exception types
223
+ - Optional regex matching of exception messages
224
+ - Access to caught exception information
225
+
226
+ Usage:
227
+ with raises(ValueError):
228
+ int("not a number")
229
+
230
+ with raises(ValueError, match="invalid literal"):
231
+ int("not a number")
232
+
233
+ with raises((ValueError, TypeError)):
234
+ some_function()
235
+
236
+ # Access the caught exception
237
+ with raises(ValueError) as exc_info:
238
+ raise ValueError("oops")
239
+ assert "oops" in str(exc_info.value)
240
+ """
241
+
242
+ def __init__(
243
+ self,
244
+ exc_type: type[BaseException] | tuple[type[BaseException], ...],
245
+ *,
246
+ match: str | None = None,
247
+ ) -> None:
248
+ super().__init__()
249
+ self.exc_type = exc_type
250
+ self.match_pattern = match
251
+ self.excinfo: ExceptionInfo | None = None
252
+
253
+ def __enter__(self) -> RaisesContext:
254
+ return self
255
+
256
+ def __exit__(
257
+ self,
258
+ exc_type: type[BaseException] | None,
259
+ exc_val: BaseException | None,
260
+ exc_tb: Any,
261
+ ) -> bool:
262
+ # No exception was raised
263
+ if exc_type is None:
264
+ exc_name = self._format_exc_name()
265
+ msg = f"DID NOT RAISE {exc_name}"
266
+ raise AssertionError(msg)
267
+
268
+ # At this point, we know an exception was raised, so exc_val cannot be None
269
+ assert exc_val is not None, "exc_val must not be None when exc_type is not None"
270
+
271
+ # Check if the exception type matches
272
+ if not issubclass(exc_type, self.exc_type):
273
+ # Unexpected exception type - let it propagate
274
+ return False
275
+
276
+ # Store the exception information
277
+ self.excinfo = ExceptionInfo(exc_type, exc_val, exc_tb)
278
+
279
+ # Check if the message matches the pattern (if provided)
280
+ if self.match_pattern is not None:
281
+ import re
282
+
283
+ exc_message = str(exc_val)
284
+ if not re.search(self.match_pattern, exc_message):
285
+ msg = (
286
+ f"Pattern {self.match_pattern!r} does not match "
287
+ f"{exc_message!r}. Exception: {exc_type.__name__}: {exc_message}"
288
+ )
289
+ raise AssertionError(msg)
290
+
291
+ # Suppress the exception (it was expected)
292
+ return True
293
+
294
+ def _format_exc_name(self) -> str:
295
+ """Format the expected exception name(s) for error messages."""
296
+ if isinstance(self.exc_type, tuple):
297
+ names = " or ".join(exc.__name__ for exc in self.exc_type)
298
+ return names
299
+ return self.exc_type.__name__
300
+
301
+ @property
302
+ def value(self) -> BaseException:
303
+ """Access the caught exception value."""
304
+ if self.excinfo is None:
305
+ msg = "No exception was caught"
306
+ raise AttributeError(msg)
307
+ return self.excinfo.value
308
+
309
+ @property
310
+ def type(self) -> type[BaseException]:
311
+ """Access the caught exception type."""
312
+ if self.excinfo is None:
313
+ msg = "No exception was caught"
314
+ raise AttributeError(msg)
315
+ return self.excinfo.type
316
+
317
+
318
+ def raises(
319
+ exc_type: type[BaseException] | tuple[type[BaseException], ...],
320
+ *,
321
+ match: str | None = None,
322
+ ) -> RaisesContext:
323
+ """Assert that code raises a specific exception.
324
+
325
+ Args:
326
+ exc_type: The expected exception type(s). Can be a single type or tuple of types.
327
+ match: Optional regex pattern to match against the exception message.
328
+
329
+ Returns:
330
+ A context manager that catches and validates the exception.
331
+
332
+ Raises:
333
+ AssertionError: If no exception is raised, or if the message doesn't match.
334
+
335
+ Usage:
336
+ with raises(ValueError):
337
+ int("not a number")
338
+
339
+ with raises(ValueError, match="invalid literal"):
340
+ int("not a number")
341
+
342
+ with raises((ValueError, TypeError)):
343
+ some_function()
344
+
345
+ # Access the caught exception
346
+ with raises(ValueError) as exc_info:
347
+ raise ValueError("oops")
348
+ assert "oops" in str(exc_info.value)
349
+ """
350
+ return RaisesContext(exc_type, match=match)
rustest/py.typed ADDED
File without changes
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from collections.abc import Iterable
6
6
  from dataclasses import dataclass
7
7
 
8
- from . import _rust
8
+ from . import rust
9
9
 
10
10
 
11
11
  @dataclass(slots=True)
@@ -23,7 +23,7 @@ class TestResult:
23
23
  stderr: str | None
24
24
 
25
25
  @classmethod
26
- def from_py(cls, result: _rust.PyTestResult) -> "TestResult":
26
+ def from_py(cls, result: rust.PyTestResult) -> "TestResult":
27
27
  return cls(
28
28
  name=result.name,
29
29
  path=result.path,
@@ -47,7 +47,7 @@ class RunReport:
47
47
  results: tuple[TestResult, ...]
48
48
 
49
49
  @classmethod
50
- def from_py(cls, report: _rust.PyRunReport) -> "RunReport":
50
+ def from_py(cls, report: rust.PyRunReport) -> "RunReport":
51
51
  return cls(
52
52
  total=report.total,
53
53
  passed=report.passed,
Binary file
@@ -19,5 +19,5 @@ def run(
19
19
  """Placeholder implementation that mirrors the extension signature."""
20
20
 
21
21
  raise NotImplementedError(
22
- "The rustest native extension is unavailable. Tests must patch rustest._rust.run."
22
+ "The rustest native extension is unavailable. Tests must patch rustest.rust.run."
23
23
  )
@@ -1,11 +1,11 @@
1
- """Type stubs for the Rust extension module."""
1
+ """Type stubs for the rustest Rust extension module."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Sequence
5
+ from typing import Sequence
6
6
 
7
7
  class PyTestResult:
8
- """Test result from Rust layer."""
8
+ """Individual test result from the Rust extension."""
9
9
 
10
10
  name: str
11
11
  path: str
@@ -16,20 +16,20 @@ class PyTestResult:
16
16
  stderr: str | None
17
17
 
18
18
  class PyRunReport:
19
- """Run report from Rust layer."""
19
+ """Test run report from the Rust extension."""
20
20
 
21
21
  total: int
22
22
  passed: int
23
23
  failed: int
24
24
  skipped: int
25
25
  duration: float
26
- results: Sequence[PyTestResult]
26
+ results: list[PyTestResult]
27
27
 
28
28
  def run(
29
- paths: list[str],
29
+ paths: Sequence[str],
30
30
  pattern: str | None,
31
31
  workers: int | None,
32
32
  capture_output: bool,
33
33
  ) -> PyRunReport:
34
- """Run tests and return a report."""
34
+ """Execute tests and return a report."""
35
35
  ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rustest
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.13
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Classifier: Programming Language :: Rust
14
14
  Classifier: Topic :: Software Development :: Testing
15
- Requires-Dist: typing-extensions>=4.8
15
+ Requires-Dist: typing-extensions>=4.15
16
16
  Requires-Dist: basedpyright>=1.19 ; extra == 'dev'
17
17
  Requires-Dist: maturin>=1.4,<2 ; extra == 'dev'
18
18
  Requires-Dist: poethepoet>=0.22 ; extra == 'dev'
@@ -30,40 +30,55 @@ Project-URL: Repository, https://github.com/Apex-Engineers-Inc/rustest
30
30
 
31
31
  # rustest
32
32
 
33
- Rustest (pronounced like Russ-Test) is a Rust-powered test runner that aims to provide the most common pytest ergonomics with a focus on raw performance. Get **78x faster** test execution with familiar syntax and minimal setup.
33
+ Rustest (pronounced like Russ-Test) is a Rust-powered test runner that aims to provide the most common pytest ergonomics with a focus on raw performance. Get **~2x faster** test execution with familiar syntax and minimal setup.
34
34
 
35
35
  ## Why rustest?
36
36
 
37
- - 🚀 **78x faster** than pytest (measured on real-world integration tests)
37
+ - 🚀 **About 2x faster** than pytest on the rustest integration test suite
38
38
  - ✅ Familiar `@fixture`, `@parametrize`, `@skip`, and `@mark` decorators
39
39
  - 🔍 Automatic test discovery (`test_*.py` and `*_test.py` files)
40
40
  - 🎯 Simple, clean API—if you know pytest, you already know rustest
41
+ - 🧮 Built-in `approx()` helper for tolerant numeric comparisons across scalars, collections, and complex numbers
42
+ - 🪤 `raises()` context manager for precise exception assertions with optional message matching
41
43
  - 📦 Easy installation with pip or uv
42
- - ⚡ Sub-10ms execution for small test suites—tests feel instant
44
+ - ⚡ Low-overhead execution keeps small suites feeling instant
43
45
 
44
46
  ## Performance
45
47
 
46
- Rustest is designed for speed. Our benchmarks show **78x faster** execution compared to pytest on the rustest integration test suite (~199 tests):
48
+ Rustest is designed for speed. Our latest benchmarks on the rustest integration suite (~200 tests) show a consistent **2.1x wall-clock speedup** over pytest:
47
49
 
48
- | Test Runner | Time | Tests/Second | Speedup |
49
- |-------------|------|--------------|---------|
50
- | pytest | 0.39s | 502 | 1.0x (baseline) |
51
- | rustest | 0.005s | 39,800 | **78x faster** |
50
+ | Test Runner | Reported Runtime† | Wall Clock‡ | Speedup (wall) | Command |
51
+ |-------------|------------------|-------------|----------------|---------|
52
+ | pytest | 0.43–0.59s | 1.33–1.59s | 1.0x (baseline) | `pytest tests/ examples/tests/ -q`
53
+ | rustest | 0.003s | 0.69–0.70s | **~2.1x faster** | `python -m rustest tests/ examples/tests/`§
52
54
 
53
- **Actual CI measurements:**
54
- - **pytest**: 196 passed, 5 skipped in 0.39s
55
- - **rustest**: 194 passed, 5 skipped in 0.005s
55
+ ### Large parametrized stress test
56
56
 
57
- **Why so fast?**
58
- - **Near-zero startup time**: Native Rust binary vs Python interpreter startup
59
- - **Rust-native test discovery**: Minimal imports until test execution
60
- - **Optimized fixture resolution**: Efficient dependency graph in Rust
61
- - **Efficient orchestration**: ~50-100μs per-test overhead vs ~1-2ms in pytest
57
+ We also profiled an extreme case with **10,000 parametrized invocations** to ensure rustest scales on synthetic but heavy workloads. The test lives in [`benchmarks/test_large_parametrize.py`](benchmarks/test_large_parametrize.py) and simply asserts `value + value == 2 * value` across every case. Running the module on its own shows a dramatic gap:
58
+
59
+ | Test Runner | Avg. Wall Clock (3 runs) | Speedup | Command |
60
+ |-------------|--------------------------|---------|---------|
61
+ | pytest | 9.72s | 1.0x | `pytest benchmarks/test_large_parametrize.py -q`§
62
+ | rustest | 0.41s | **~24x** | `python -m rustest benchmarks/test_large_parametrize.py`§
63
+
64
+ † pytest and rustest both report only active test execution time; rustest's figure omits Python interpreter start-up overhead.
65
+
66
+ ‡ Integration-suite wall-clock timing measured with the shell `time` builtin across two consecutive runs in the same environment.
67
+
68
+ § Commands executed with `PYTHONPATH=python` in this repository checkout to exercise the local sources. Pytest relies on a small compatibility shim in [`benchmarks/conftest.py`](benchmarks/conftest.py) so it understands the rustest-style decorators. Large-parametrization timings come from averaging three `time.perf_counter()` measurements with output suppressed via `subprocess.DEVNULL`.
69
+
70
+ Rustest counts parametrized cases slightly differently than pytest, so you will see 199 executed cases vs. pytest's 201 discoveries on the same suite—the reported pass/skip counts still align.
71
+
72
+ **Why is rustest faster?**
73
+ - **Near-zero startup time**: Native Rust binary minimizes overhead before Python code starts running.
74
+ - **Rust-native test discovery**: Minimal imports until test execution keeps collection quick.
75
+ - **Optimized fixture resolution**: Efficient dependency graph resolution reduces per-test work.
76
+ - **Lean orchestration**: Rust handles scheduling and reporting so the Python interpreter focuses on running test bodies.
62
77
 
63
78
  **Real-world impact:**
64
- - **200 tests**: 0.39s → 0.005s (instant feedback)
65
- - **1,000 tests**: ~2s → ~0.025s (tests complete before you can switch tabs)
66
- - **10,000 tests**: ~20s → ~0.25s (dramatically faster feedback loops)
79
+ - **200 tests** (this repository): 1.46s → 0.70s (average wall-clock, ~0.76s saved per run)
80
+ - **1,000 tests** (projected): ~7.3s → ~3.4s assuming similar scaling
81
+ - **10,000 tests** (projected): ~73s → ~34s—minutes saved across CI runs
67
82
 
68
83
  See [BENCHMARKS.md](BENCHMARKS.md) for detailed performance analysis and methodology.
69
84
 
@@ -91,14 +106,14 @@ If you want to contribute to rustest, see [DEVELOPMENT.md](DEVELOPMENT.md) for s
91
106
  Create a file `test_math.py`:
92
107
 
93
108
  ```python
94
- from rustest import fixture, parametrize, mark
109
+ from rustest import fixture, parametrize, mark, approx, raises
95
110
 
96
111
  @fixture
97
112
  def numbers() -> list[int]:
98
113
  return [1, 2, 3, 4, 5]
99
114
 
100
115
  def test_sum(numbers: list[int]) -> None:
101
- assert sum(numbers) == 15
116
+ assert sum(numbers) == approx(15)
102
117
 
103
118
  @parametrize("value,expected", [(2, 4), (3, 9), (4, 16)])
104
119
  def test_square(value: int, expected: int) -> None:
@@ -109,6 +124,10 @@ def test_expensive_operation() -> None:
109
124
  # This test is marked as slow for filtering
110
125
  result = sum(range(1000000))
111
126
  assert result > 0
127
+
128
+ def test_division_by_zero_is_reported() -> None:
129
+ with raises(ZeroDivisionError, match="division by zero"):
130
+ 1 / 0
112
131
  ```
113
132
 
114
133
  ### 2. Run Your Tests
@@ -229,6 +248,21 @@ def test_api_configuration(api_client: dict) -> None:
229
248
  assert api_client["timeout"] == 30
230
249
  ```
231
250
 
251
+ #### Assertion Helpers
252
+
253
+ Rustest ships helpers for expressive assertions:
254
+
255
+ ```python
256
+ from rustest import approx, raises
257
+
258
+ def test_nearly_equal() -> None:
259
+ assert 0.1 + 0.2 == approx(0.3, rel=1e-9)
260
+
261
+ def test_raises_with_message() -> None:
262
+ with raises(ValueError, match="invalid configuration"):
263
+ raise ValueError("invalid configuration")
264
+ ```
265
+
232
266
  #### Yield Fixtures with Setup/Teardown
233
267
 
234
268
  Fixtures can use `yield` to perform cleanup after tests:
@@ -407,6 +441,142 @@ def test_full_workflow() -> None:
407
441
  pass
408
442
  ```
409
443
 
444
+ #### Test Classes
445
+
446
+ Rustest supports pytest-style test classes, allowing you to organize related tests together:
447
+
448
+ ```python
449
+ from rustest import fixture, parametrize, mark
450
+
451
+ class TestBasicMath:
452
+ """Group related tests in a class."""
453
+
454
+ def test_addition(self):
455
+ assert 1 + 1 == 2
456
+
457
+ def test_subtraction(self):
458
+ assert 5 - 3 == 2
459
+
460
+ def test_multiplication(self):
461
+ assert 3 * 4 == 12
462
+ ```
463
+
464
+ **Using Fixtures in Test Classes:**
465
+
466
+ Test methods can inject fixtures just like standalone test functions:
467
+
468
+ ```python
469
+ from rustest import fixture
470
+
471
+ @fixture
472
+ def calculator():
473
+ return {"add": lambda x, y: x + y, "multiply": lambda x, y: x * y}
474
+
475
+ class TestCalculator:
476
+ """Test class using fixtures."""
477
+
478
+ def test_addition(self, calculator):
479
+ assert calculator["add"](2, 3) == 5
480
+
481
+ def test_multiplication(self, calculator):
482
+ assert calculator["multiply"](4, 5) == 20
483
+ ```
484
+
485
+ **Class-Scoped Fixtures:**
486
+
487
+ Class-scoped fixtures are shared across all test methods in the same class, perfect for expensive setup operations:
488
+
489
+ ```python
490
+ from rustest import fixture
491
+
492
+ @fixture(scope="class")
493
+ def database():
494
+ """Expensive setup shared across all tests in a class."""
495
+ db = {"connection": "db://test", "data": []}
496
+ return db
497
+
498
+ class TestDatabase:
499
+ """All tests share the same database fixture instance."""
500
+
501
+ def test_connection(self, database):
502
+ assert database["connection"] == "db://test"
503
+
504
+ def test_add_data(self, database):
505
+ database["data"].append("item1")
506
+ assert len(database["data"]) >= 1
507
+
508
+ def test_data_persists(self, database):
509
+ # Same database instance, so previous test's data is still there
510
+ assert len(database["data"]) >= 1
511
+ ```
512
+
513
+ **Fixture Methods Within Test Classes:**
514
+
515
+ You can define fixtures as methods inside test classes, providing class-specific setup:
516
+
517
+ ```python
518
+ from rustest import fixture
519
+
520
+ class TestWithFixtureMethod:
521
+ """Test class with its own fixture methods."""
522
+
523
+ @fixture(scope="class")
524
+ def class_resource(self):
525
+ """Fixture method shared across tests in this class."""
526
+ resource = {"value": 42, "name": "test_resource"}
527
+ yield resource
528
+ # Teardown happens after all tests in class
529
+ resource["closed"] = True
530
+
531
+ @fixture
532
+ def per_test_data(self, class_resource):
533
+ """Fixture method that depends on another fixture."""
534
+ return {"id": id(self), "resource": class_resource}
535
+
536
+ def test_uses_class_resource(self, class_resource):
537
+ assert class_resource["value"] == 42
538
+
539
+ def test_uses_per_test_data(self, per_test_data):
540
+ assert "resource" in per_test_data
541
+ assert per_test_data["resource"]["value"] == 42
542
+ ```
543
+
544
+ **Class Variables and Instance Variables:**
545
+
546
+ Test classes can use class variables for shared state and instance variables for per-test isolation:
547
+
548
+ ```python
549
+ class TestWithVariables:
550
+ """Test class with class and instance variables."""
551
+
552
+ class_variable = "shared_data" # Shared across all tests
553
+
554
+ def test_class_variable(self):
555
+ # Access class variable
556
+ assert self.class_variable == "shared_data"
557
+ assert TestWithVariables.class_variable == "shared_data"
558
+
559
+ def test_instance_variable(self):
560
+ # Each test gets a fresh instance
561
+ self.instance_var = "test_specific"
562
+ assert self.instance_var == "test_specific"
563
+ ```
564
+
565
+ **Parametrized Test Methods:**
566
+
567
+ Use `@parametrize` on class methods just like regular test functions:
568
+
569
+ ```python
570
+ from rustest import parametrize
571
+
572
+ class TestParametrized:
573
+ """Test class with parametrized methods."""
574
+
575
+ @parametrize("value,expected", [(2, 4), (3, 9), (4, 16)])
576
+ def test_square(self, value, expected):
577
+ assert value ** 2 == expected
578
+ ```
579
+
410
580
  ### Test Output
411
581
 
412
582
  When you run rustest, you'll see clean, informative output:
@@ -436,13 +606,14 @@ Rustest aims to provide the most commonly-used pytest features with dramatically
436
606
  | **Core Test Discovery** |
437
607
  | `test_*.py` / `*_test.py` files | ✅ | ✅ | Rustest uses Rust for dramatically faster discovery |
438
608
  | Test function detection (`test_*`) | ✅ | ✅ | |
439
- | Test class detection (`Test*`) | ✅ | ✅ | via `unittest.TestCase` support |
609
+ | Test class detection (`Test*`) | ✅ | ✅ | Full pytest-style class support with fixture methods |
440
610
  | Pattern-based filtering | ✅ | ✅ | `-k` pattern matching |
441
611
  | **Fixtures** |
442
612
  | `@fixture` decorator | ✅ | ✅ | Rust-based dependency resolution |
443
613
  | Fixture dependency injection | ✅ | ✅ | Much faster in rustest |
444
614
  | Fixture scopes (function/class/module/session) | ✅ | ✅ | Full support for all scopes |
445
615
  | Yield fixtures (setup/teardown) | ✅ | ✅ | Full support with cleanup |
616
+ | Fixture methods within test classes | ✅ | ✅ | Define fixtures as class methods |
446
617
  | Fixture parametrization | ✅ | 🚧 | Planned |
447
618
  | **Parametrization** |
448
619
  | `@parametrize` decorator | ✅ | ✅ | Full support with custom IDs |
@@ -0,0 +1,16 @@
1
+ rustest-0.3.0.dist-info/METADATA,sha256=0A5bldOq84__CL4XWiYg5vnQdoHBiO-OmcVTXrpIcQo,20349
2
+ rustest-0.3.0.dist-info/WHEEL,sha256=nw13728gRokCivwI6SCanennWBgDOflh2lHvovMwAi8,104
3
+ rustest-0.3.0.dist-info/entry_points.txt,sha256=7fUa3LO8vudQ4dKG1sTRaDnxcMdBSZsWs9EyuxFQ7Lk,48
4
+ rustest-0.3.0.dist-info/licenses/LICENSE,sha256=s64ibUGtb6jEDBsYuxUFtMr_c4PaqYP-vj3YY6QtTGw,1075
5
+ rustest/__init__.py,sha256=0CkHfrmIjpGw6DMu2VcZLOUpBVtdwsN-aitn-RzOglo,514
6
+ rustest/__main__.py,sha256=bBvo5gsSluUzlDTDvn5bP_gZZEXMwJQZMqVA5W1M1v8,178
7
+ rustest/approx.py,sha256=MKmuorBBHqpH0h0QaIMVjbm3-mXJ0E90limEgSHHVfw,5744
8
+ rustest/cli.py,sha256=fO6D09b7Se6aqC8EHwA0jCy2tokrgX9fYcNhMni_5Xs,8699
9
+ rustest/core.py,sha256=8gZ1VsFtc3rUwyhwREm74e03E-sB8iNp4sJb2w8hUBk,485
10
+ rustest/decorators.py,sha256=nijzNG8NQXZd8kEfMjhSB-85gLoIjaIMxwZQNUNWgrE,11373
11
+ rustest/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ rustest/reporting.py,sha256=3-R8aljv2ZbmjOa9Q9KZeCPsMaitm8nZ96LoJS_NnUQ,1623
13
+ rustest/rust.cpython-312-darwin.so,sha256=yDbdByHqH9DPDzHTxnRO8zLRTtEUXMpwMWQN8uOPSNE,1466544
14
+ rustest/rust.py,sha256=tCIvjYd06VxoT_rKvv2o8CpXW_pFNua5VgcRDjLgU78,659
15
+ rustest/rust.pyi,sha256=lezCHLSIedCPQ1fc6Bd5Vxc34AhI8IOgIFNrNLRxX7Y,706
16
+ rustest-0.3.0.dist-info/RECORD,,
Binary file
@@ -1,14 +0,0 @@
1
- rustest-0.1.0.dist-info/METADATA,sha256=MxCLrEAs8gtwnesn3cpHVwnD-lzCdw7A03a2aTmxrvI,13963
2
- rustest-0.1.0.dist-info/WHEEL,sha256=nw13728gRokCivwI6SCanennWBgDOflh2lHvovMwAi8,104
3
- rustest-0.1.0.dist-info/entry_points.txt,sha256=7fUa3LO8vudQ4dKG1sTRaDnxcMdBSZsWs9EyuxFQ7Lk,48
4
- rustest-0.1.0.dist-info/licenses/LICENSE,sha256=s64ibUGtb6jEDBsYuxUFtMr_c4PaqYP-vj3YY6QtTGw,1075
5
- rustest/__init__.py,sha256=K7MyGPARnn97RSjk-E_x_1KjqD49RK4fII_sZ_0rdcc,439
6
- rustest/__main__.py,sha256=nqdz6DhrDze715SXxtzAYV2sie3CPoy7IvWCdcyHJEM,179
7
- rustest/_cli.py,sha256=kq9LAwHaJmZ-gnAlTsz7Ov8r1fiDvNoLf4hEI3sxhng,8700
8
- rustest/_decorators.py,sha256=EFcAmZfThiyj_J5l6fEx7Ix9LroXIBrOwa8QuxltNLI,6561
9
- rustest/_reporting.py,sha256=6nVcccX1dgEBW72wCOeOIl5I-OE-ukjJD0VQs56pwjo,1626
10
- rustest/_rust.cpython-312-darwin.so,sha256=8UZE-rrDiDzt935EmW22CWWSjS5dtNQisjKVzAKglVg,1466832
11
- rustest/_rust.py,sha256=k3nXhGiehOVY_S6w28rIdrc0CEc3gFLgwWVOEMcPOZo,660
12
- rustest/_rust.pyi,sha256=fDFLX0qj4G_bV1sHmTtRPI26grTDG_LFzPFEqp5vFGk,671
13
- rustest/core.py,sha256=xmBUpuPs0r0HQthc9J5dCQYkZnXqfxqIfSGkHeoqQS4,488
14
- rustest-0.1.0.dist-info/RECORD,,