Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions crates/karva/tests/it/extensions/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,250 @@ def test_raise_skip_error():
----- stderr -----
");
}

#[test]
fn test_raises_matching_exception() {
let context = TestContext::with_file(
"test.py",
r"
import karva

def test_raises_value_error():
with karva.raises(ValueError):
raise ValueError('oops')
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_raises_value_error ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
fn test_raises_no_exception() {
let context = TestContext::with_file(
"test.py",
r"
import karva

def test_raises_no_exception():
with karva.raises(ValueError):
pass
",
);

assert_cmd_snapshot!(context.command(), @r"
success: false
exit_code: 1
----- stdout -----
test test::test_raises_no_exception ... FAILED

diagnostics:

error[test-failure]: Test `test_raises_no_exception` failed
--> test.py:4:5
|
2 | import karva
3 |
4 | def test_raises_no_exception():
| ^^^^^^^^^^^^^^^^^^^^^^^^
5 | with karva.raises(ValueError):
6 | pass
|
info: Test failed here
--> test.py:5:5
|
4 | def test_raises_no_exception():
5 | with karva.raises(ValueError):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
6 | pass
|
info: DID NOT RAISE <class 'ValueError'>

test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
fn test_raises_with_match() {
let context = TestContext::with_file(
"test.py",
r"
import karva

def test_raises_match_passes():
with karva.raises(ValueError, match='oops'):
raise ValueError('oops something happened')
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_raises_match_passes ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
fn test_raises_with_match_fails() {
let context = TestContext::with_file(
"test.py",
r"
import karva

def test_raises_match_fails():
with karva.raises(ValueError, match='xyz'):
raise ValueError('oops')
",
);

assert_cmd_snapshot!(context.command(), @r"
success: false
exit_code: 1
----- stdout -----
test test::test_raises_match_fails ... FAILED

diagnostics:

error[test-failure]: Test `test_raises_match_fails` failed
--> test.py:4:5
|
2 | import karva
3 |
4 | def test_raises_match_fails():
| ^^^^^^^^^^^^^^^^^^^^^^^
5 | with karva.raises(ValueError, match='xyz'):
6 | raise ValueError('oops')
|
info: Test failed here
--> test.py:5:5
|
4 | def test_raises_match_fails():
5 | with karva.raises(ValueError, match='xyz'):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
6 | raise ValueError('oops')
|
info: Raised exception did not match pattern 'xyz'

test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
fn test_raises_wrong_exception_type() {
let context = TestContext::with_file(
"test.py",
r"
import karva

def test_raises_wrong_type():
with karva.raises(ValueError):
raise TypeError('wrong type')
",
);

assert_cmd_snapshot!(context.command(), @r"
success: false
exit_code: 1
----- stdout -----
test test::test_raises_wrong_type ... FAILED

diagnostics:

error[test-failure]: Test `test_raises_wrong_type` failed
--> test.py:4:5
|
2 | import karva
3 |
4 | def test_raises_wrong_type():
| ^^^^^^^^^^^^^^^^^^^^^^
5 | with karva.raises(ValueError):
6 | raise TypeError('wrong type')
|
info: Test failed here
--> test.py:6:9
|
4 | def test_raises_wrong_type():
5 | with karva.raises(ValueError):
6 | raise TypeError('wrong type')
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
info: wrong type

test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
fn test_raises_exc_info() {
let context = TestContext::with_file(
"test.py",
r"
import karva

def test_raises_exc_info():
with karva.raises(ValueError) as exc_info:
raise ValueError('info test')
assert str(exc_info.value) == 'info test'
assert exc_info.type is ValueError
assert exc_info.tb is not None
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_raises_exc_info ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
fn test_raises_subclass() {
let context = TestContext::with_file(
"test.py",
r"
import karva

class CustomError(ValueError):
pass

def test_raises_subclass():
with karva.raises(ValueError):
raise CustomError('subclass')
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_raises_subclass ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}
2 changes: 2 additions & 0 deletions crates/karva_core/src/extensions/functions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
pub use self::raises::{ExceptionInfo, RaisesContext};
use pyo3::prelude::*;
pub use python::Param;

pub mod python;
pub mod raises;

// SkipError exception that can be raised to skip tests at runtime with an optional reason
pyo3::create_exception!(karva, SkipError, pyo3::exceptions::PyException);
Expand Down
105 changes: 105 additions & 0 deletions crates/karva_core/src/extensions/functions/raises.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
use pyo3::prelude::*;
use pyo3::types::PyType;

use super::FailError;

#[pyclass]
pub struct ExceptionInfo {
#[pyo3(get, name = "type")]
pub exc_type: Option<Py<PyAny>>,

#[pyo3(get)]
pub value: Option<Py<PyAny>>,

#[pyo3(get)]
pub tb: Option<Py<PyAny>>,
}

#[pymethods]
impl ExceptionInfo {
#[new]
fn new() -> Self {
Self {
exc_type: None,
value: None,
tb: None,
}
}
}

#[pyclass]
pub struct RaisesContext {
expected_exception: Py<PyAny>,
match_pattern: Option<String>,
exc_info: Py<ExceptionInfo>,
}

#[pymethods]
impl RaisesContext {
fn __enter__(&self, py: Python<'_>) -> Py<ExceptionInfo> {
self.exc_info.clone_ref(py)
}

fn __exit__(
&self,
py: Python<'_>,
exc_type: Option<Py<PyAny>>,
exc_val: Option<Py<PyAny>>,
exc_tb: Option<Py<PyAny>>,
) -> PyResult<bool> {
let Some(exc_type_obj) = exc_type else {
let repr = self.expected_exception.bind(py).repr()?.to_string();
return Err(FailError::new_err(format!("DID NOT RAISE {repr}")));
};

let exc_type_bound = exc_type_obj.bind(py);
let expected_bound = self.expected_exception.bind(py);

let exc_py_type = exc_type_bound.cast::<PyType>()?;
let expected_py_type = expected_bound.cast::<PyType>()?;

if !exc_py_type.is_subclass(expected_py_type)? {
return Ok(false);
}

if let Some(ref pattern) = self.match_pattern {
let exc_str = if let Some(ref val) = exc_val {
val.bind(py).str()?.to_string()
} else {
String::new()
};

let re_module = py.import("re")?;
let result = re_module.call_method1("search", (pattern.as_str(), exc_str.as_str()))?;

if result.is_none() {
return Err(FailError::new_err(format!(
"Raised exception did not match pattern '{pattern}'"
)));
}
}

let mut info = self.exc_info.borrow_mut(py);
info.exc_type = Some(exc_type_obj);
info.value = exc_val;
info.tb = exc_tb;

Ok(true)
}
}

/// Assert that a block of code raises a specific exception.
#[pyfunction]
#[pyo3(signature = (expected_exception, *, r#match = None))]
pub fn raises(
py: Python<'_>,
expected_exception: Py<PyAny>,
r#match: Option<String>,
) -> PyResult<RaisesContext> {
let exc_info = Py::new(py, ExceptionInfo::new())?;
Ok(RaisesContext {
expected_exception,
match_pattern: r#match,
exc_info,
})
}
8 changes: 7 additions & 1 deletion crates/karva_core/src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ use crate::extensions::fixtures::MockEnv;
use crate::extensions::fixtures::python::{
FixtureFunctionDefinition, FixtureFunctionMarker, InvalidFixtureError, fixture_decorator,
};
use crate::extensions::functions::{FailError, SkipError, fail, param, skip};
use crate::extensions::functions::raises::raises;
use crate::extensions::functions::{
ExceptionInfo, FailError, RaisesContext, SkipError, fail, param, skip,
};
use crate::extensions::tags::python::{PyTags, PyTestFunction, tags};

pub fn init_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(fixture_decorator, m)?)?;
m.add_function(wrap_pyfunction!(skip, m)?)?;
m.add_function(wrap_pyfunction!(fail, m)?)?;
m.add_function(wrap_pyfunction!(param, m)?)?;
m.add_function(wrap_pyfunction!(raises, m)?)?;

m.add_class::<FixtureFunctionMarker>()?;
m.add_class::<FixtureFunctionDefinition>()?;
m.add_class::<PyTags>()?;
m.add_class::<PyTestFunction>()?;
m.add_class::<MockEnv>()?;
m.add_class::<ExceptionInfo>()?;
m.add_class::<RaisesContext>()?;

m.add_wrapped(wrap_pymodule!(tags))?;

Expand Down
Loading
Loading