From 6907a5336b15a7a6bfd6fe9d4922fe52d6079edb Mon Sep 17 00:00:00 2001 From: "Stefan J. Wernli" Date: Wed, 1 Jul 2026 15:35:42 -0700 Subject: [PATCH 1/8] Automatic discovery and execution of Q# tests from Python --- source/compiler/qsc/src/interpret.rs | 25 +++++--- .../qsc/src/interpret/circuit_tests.rs | 10 ++-- source/compiler/qsc/src/interpret/tests.rs | 42 +++++++------ source/compiler/qsc_frontend/src/resolve.rs | 4 +- source/compiler/qsc_hir/src/global.rs | 12 ++-- source/qdk_package/qdk/_context.py | 60 +++++++++++++++++-- source/qdk_package/qdk/_interpreter.py | 9 +++ source/qdk_package/qdk/_native.pyi | 2 +- source/qdk_package/qdk/qsharp.py | 2 + source/qdk_package/src/interpreter.rs | 36 ++++++++--- source/resource_estimator/src/counts/tests.rs | 10 +++- 11 files changed, 161 insertions(+), 51 deletions(-) diff --git a/source/compiler/qsc/src/interpret.rs b/source/compiler/qsc/src/interpret.rs index 70832e5a52e..1cd1d6b30e1 100644 --- a/source/compiler/qsc/src/interpret.rs +++ b/source/compiler/qsc/src/interpret.rs @@ -440,7 +440,7 @@ impl Interpreter { /// Given a package ID, returns all the global items in the package. /// Note this does not currently include re-exports. - fn package_globals(&self, package_id: PackageId) -> Vec<(Vec>, Rc, Value)> { + fn package_globals(&self, package_id: PackageId) -> Vec { let mut exported_items = Vec::new(); let package = &self .compiler @@ -454,24 +454,25 @@ impl Interpreter { package: package_id, item: fir::LocalItemId::from(usize::from(term.id.item)), }; - exported_items.push(( - global.namespace, - global.name, - Value::Global(store_item_id, FunctorApp::default()), - )); + exported_items.push(PackageGlobal { + namespace: global.namespace, + name: global.name, + value: Value::Global(store_item_id, FunctorApp::default()), + is_test: term.is_test, + }); } } exported_items } /// Get the global callables defined in the user source passed into initialization of the interpreter as `Value` instances. - pub fn source_globals(&self) -> Vec<(Vec>, Rc, Value)> { + pub fn source_globals(&self) -> Vec { self.package_globals(self.source_package) } /// Get the global callables defined in the open package being interpreted as `Value` instances, which will include any items /// defined by calls to `eval_fragments` and the like. - pub fn user_globals(&self) -> Vec<(Vec>, Rc, Value)> { + pub fn user_globals(&self) -> Vec { self.package_globals(self.package) } @@ -1811,6 +1812,14 @@ pub enum CircuitGenerationMethod { Static, } +/// A global item as enumerated from a package. +pub struct PackageGlobal { + pub namespace: Vec>, + pub name: Rc, + pub value: Value, + pub is_test: bool, +} + /// A debugger that enables step-by-step evaluation of code /// and inspecting state in the interpreter. pub struct Debugger { diff --git a/source/compiler/qsc/src/interpret/circuit_tests.rs b/source/compiler/qsc/src/interpret/circuit_tests.rs index 76af72326a2..ad06feb4ed3 100644 --- a/source/compiler/qsc/src/interpret/circuit_tests.rs +++ b/source/compiler/qsc/src/interpret/circuit_tests.rs @@ -5,7 +5,7 @@ use super::{CircuitEntryPoint, Debugger, Interpreter}; use crate::{ - interpret::{CircuitGenerationMethod, Error}, + interpret::{CircuitGenerationMethod, Error, PackageGlobal}, target::Profile, }; use expect_test::expect; @@ -280,15 +280,15 @@ fn static_circuit_from_callable_with_callable_arg_matches_classical_eval() { let globals = interp.source_globals(); let invoke = globals .iter() - .find(|(_, n, _)| &**n == "InvokeWithQubits") + .find(|PackageGlobal { name, .. }| &**name == "InvokeWithQubits") .expect("InvokeWithQubits should be a source global") - .2 + .value .clone(); let all_h = globals .iter() - .find(|(_, n, _)| &**n == "AllH") + .find(|PackageGlobal { name, .. }| &**name == "AllH") .expect("AllH should be a source global") - .2 + .value .clone(); let args = Value::Tuple(vec![Value::Int(3), all_h].into(), None); diff --git a/source/compiler/qsc/src/interpret/tests.rs b/source/compiler/qsc/src/interpret/tests.rs index 664e7a5d8d8..19c53d117a8 100644 --- a/source/compiler/qsc/src/interpret/tests.rs +++ b/source/compiler/qsc/src/interpret/tests.rs @@ -73,6 +73,8 @@ mod given_interpreter { use expect_test::expect; use indoc::indoc; + use crate::interpret::PackageGlobal; + use super::*; mod without_stdlib { @@ -674,17 +676,17 @@ mod given_interpreter { let items = interpreter.user_globals(); assert_eq!(items.len(), 2); // No namespace for top-level items - assert!(items[0].0.is_empty()); + assert!(items[0].namespace.is_empty()); expect![[r#" "Foo" "#]] - .assert_debug_eq(&items[0].1); + .assert_debug_eq(&items[0].name); // No namespace for top-level items - assert!(items[1].0.is_empty()); + assert!(items[1].namespace.is_empty()); expect![[r#" "Bar" "#]] - .assert_debug_eq(&items[1].1); + .assert_debug_eq(&items[1].name); } #[test] @@ -706,11 +708,11 @@ mod given_interpreter { "Foo", ] "#]] - .assert_debug_eq(&items[0].0); + .assert_debug_eq(&items[0].namespace); expect![[r#" "Bar" "#]] - .assert_debug_eq(&items[0].1); + .assert_debug_eq(&items[0].name); } #[test] @@ -737,29 +739,29 @@ mod given_interpreter { let items = interpreter.user_globals(); assert_eq!(items.len(), 4); // No namespace for top-level items - assert!(items[0].0.is_empty()); + assert!(items[0].namespace.is_empty()); expect![[r#" "Foo" "#]] - .assert_debug_eq(&items[0].1); + .assert_debug_eq(&items[0].name); // No namespace for top-level items - assert!(items[1].0.is_empty()); + assert!(items[1].namespace.is_empty()); expect![[r#" "Bar" "#]] - .assert_debug_eq(&items[1].1); + .assert_debug_eq(&items[1].name); // No namespace for top-level items - assert!(items[2].0.is_empty()); + assert!(items[2].namespace.is_empty()); expect![[r#" "Baz" "#]] - .assert_debug_eq(&items[2].1); + .assert_debug_eq(&items[2].name); // No namespace for top-level items - assert!(items[3].0.is_empty()); + assert!(items[3].namespace.is_empty()); expect![[r#" "Qux" "#]] - .assert_debug_eq(&items[3].1); + .assert_debug_eq(&items[3].name); } #[test] @@ -1105,7 +1107,13 @@ mod given_interpreter { interpreter .user_globals() .into_iter() - .find_map(|(_, global_name, value)| (global_name.as_ref() == name).then_some(value)) + .find_map( + |PackageGlobal { + name: global_name, + value, + .. + }| { (global_name.as_ref() == name).then_some(value) }, + ) .unwrap_or_else(|| panic!("{name} should be present in user globals")) } @@ -2952,11 +2960,11 @@ mod given_interpreter { "A", ] "#]] - .assert_debug_eq(&items[0].0); + .assert_debug_eq(&items[0].namespace); expect![[r#" "B" "#]] - .assert_debug_eq(&items[0].1); + .assert_debug_eq(&items[0].namespace); } #[test] diff --git a/source/compiler/qsc_frontend/src/resolve.rs b/source/compiler/qsc_frontend/src/resolve.rs index ce81a2a0bb5..73ea8da3dfb 100644 --- a/source/compiler/qsc_frontend/src/resolve.rs +++ b/source/compiler/qsc_frontend/src/resolve.rs @@ -1278,7 +1278,7 @@ impl GlobalTable { for global in global::iter_package(id, package).filter(|global| { global.visibility == hir::Visibility::Public - || matches!(&global.kind, global::Kind::Callable(t) if t.intrinsic) + || matches!(&global.kind, global::Kind::Callable(t) if t.is_intrinsic) }) { // If the namespace is `Main` and we have an alias, we treat it as the root of the package, so there's no // namespace prefix between the dependency alias and the defined items. @@ -1322,7 +1322,7 @@ impl GlobalTable { Res::Importable(Importable::Callable(term.id, global.status)), ); } - if term.intrinsic { + if term.is_intrinsic { self.scope.intrinsics.insert(global.name); } } diff --git a/source/compiler/qsc_hir/src/global.rs b/source/compiler/qsc_hir/src/global.rs index cb28ab420dc..1239564f175 100644 --- a/source/compiler/qsc_hir/src/global.rs +++ b/source/compiler/qsc_hir/src/global.rs @@ -3,7 +3,8 @@ use crate::{ hir::{ - Item, ItemId, ItemKind, ItemStatus, Package, PackageId, Res, SpecBody, SpecGen, Visibility, + Attr, Item, ItemId, ItemKind, ItemStatus, Package, PackageId, Res, SpecBody, SpecGen, + Visibility, }, ty::Scheme, }; @@ -48,7 +49,8 @@ pub struct Ty { pub struct Callable { pub id: ItemId, pub scheme: Scheme, - pub intrinsic: bool, + pub is_intrinsic: bool, + pub is_test: bool, } /// A lookup table used for looking up global core items for insertion in `qsc_passes`. @@ -144,7 +146,8 @@ impl PackageIter<'_> { kind: Kind::Callable(Callable { id: item_id, scheme: decl.scheme(), - intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic), + is_intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic), + is_test: decl.attrs.contains(&Attr::Test), }), }), (ItemKind::Callable(decl), None) => Some(Global { @@ -155,7 +158,8 @@ impl PackageIter<'_> { kind: Kind::Callable(Callable { id: item_id, scheme: decl.scheme(), - intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic), + is_intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic), + is_test: decl.attrs.contains(&Attr::Test), }), }), (ItemKind::Ty(name, _def), Some(ItemKind::Namespace(namespace, _))) => Some(Global { diff --git a/source/qdk_package/qdk/_context.py b/source/qdk_package/qdk/_context.py index ab764757a75..eb7b32d0dba 100644 --- a/source/qdk_package/qdk/_context.py +++ b/source/qdk_package/qdk/_context.py @@ -252,12 +252,15 @@ def __init__( self_ref = weakref.ref(self) def make_callable_weak( - callable: GlobalCallable, namespace: List[str], callable_name: str + callable: GlobalCallable, + namespace: List[str], + callable_name: str, + is_test: bool, ) -> None: ctx = self_ref() if ctx is None or ctx._disposed: return - ctx._make_callable(callable, namespace, callable_name) + ctx._make_callable(callable, namespace, callable_name, is_test) def make_class_weak( qsharp_type: TypeIR, namespace: List[str], class_name: str @@ -433,7 +436,11 @@ def _get_code_module( return module def _make_callable( - self, callable: GlobalCallable, namespace: List[str], callable_name: str + self, + callable: GlobalCallable, + namespace: List[str], + callable_name: str, + is_test: bool, ): """Registers a Q# callable in this context's code module.""" module = self._get_code_module(namespace) @@ -452,12 +459,14 @@ def _callable_fn(*args): setattr(_callable_fn, "_qdk_context", self) setattr(_callable_fn, "__global_callable", callable) + setattr(_callable_fn, "__is_test__", is_test) + setattr(_callable_fn, "__name__", ".".join(namespace + [callable_name])) if module.__dict__.get(callable_name) is None: module.__setattr__(callable_name, _callable_fn) else: for key, val in module.__dict__.get(callable_name).__dict__.items(): - if key != "__global_callable": + if key != "__global_callable" and key != "__is_test__": _callable_fn.__dict__[key] = val module.__setattr__(callable_name, _callable_fn) @@ -1021,3 +1030,46 @@ def import_openqasm( def get_target_profile(self) -> TargetProfile: """Returns target profile for this Context.""" return self._target_profile + + def _get_test_callables(self) -> List[Callable]: + """Returns a list of all Q# callables with the `@Test` attribute in this Context.""" + test_callables: List[Callable] = [] + + # Iterate through all the attributes of self.code and check if they are callables with the __is_test__ attribute set to True + # Recursively check for nested modules as well + def find_test_callables(module): + for attr_name in dir(module): + attr = getattr(module, attr_name) + if callable(attr) and getattr(attr, "__is_test__", False): + test_callables.append(attr) + elif isinstance(attr, types.ModuleType) or isinstance( + attr, types.SimpleNamespace + ): + find_test_callables(attr) + + find_test_callables(self.code) + return test_callables + + def run_tests(self, seed: Optional[int] = None) -> None: + """ + Runs all Q# callables with the `@Test` attribute in this Context. + + :param seed: The seed to use for the random number generator in simulation, if any. + """ + tests = self._get_test_callables() + print("Starting tests...") + failed_tests = [] + for test in tests: + print(f"Running `{test.__name__}`...") + try: + self.run(test, 1, seed=seed) + print(f"`{test.__name__}` passed") + except Exception as e: + print(f"`{test.__name__}` FAILED with exception:\n{e}") + failed_tests.append(test.__name__) + print( + f"Finished tests: {len(tests) - len(failed_tests)} passed, {len(failed_tests)} failed." + ) + if failed_tests: + for failed_test in failed_tests: + print(f" - `{failed_test}` FAILED") diff --git a/source/qdk_package/qdk/_interpreter.py b/source/qdk_package/qdk/_interpreter.py index 8324259256b..89ad02ea2c4 100644 --- a/source/qdk_package/qdk/_interpreter.py +++ b/source/qdk_package/qdk/_interpreter.py @@ -456,6 +456,15 @@ def logical_counts( return _get_default_context().logical_counts(entry_expr, *args) +def run_tests(seed: Optional[int] = None) -> None: + """ + Runs all Q# callables with the `@Test` attribute in the current context. + + :param seed: The seed to use for the random number generator in simulation, if any. + """ + _get_default_context().run_tests(seed) + + def set_quantum_seed(seed: Optional[int]) -> None: """ Sets the seed for the random number generator used for quantum measurements. diff --git a/source/qdk_package/qdk/_native.pyi b/source/qdk_package/qdk/_native.pyi index 41e4be1bb3f..7788caf3a41 100644 --- a/source/qdk_package/qdk/_native.pyi +++ b/source/qdk_package/qdk/_native.pyi @@ -156,7 +156,7 @@ class Interpreter: list_directory: Callable[[str], List[Dict[str, str]]], resolve_path: Callable[[str, str], str], fetch_github: Callable[[str, str, str, str], str], - make_callable: Optional[Callable[[GlobalCallable, List[str], str], None]], + make_callable: Optional[Callable[[GlobalCallable, List[str], str, bool], None]], make_class: Optional[Callable[[TypeIR, List[str], str], None]], trace_circuit: Optional[bool], ) -> None: diff --git a/source/qdk_package/qdk/qsharp.py b/source/qdk_package/qdk/qsharp.py index a5d8d65e11a..86a486a5161 100644 --- a/source/qdk_package/qdk/qsharp.py +++ b/source/qdk_package/qdk/qsharp.py @@ -49,6 +49,7 @@ circuit, estimate, logical_counts, + run_tests, set_quantum_seed, set_classical_seed, dump_machine, @@ -73,6 +74,7 @@ "circuit", "estimate", "logical_counts", + "run_tests", # Seed / state "set_quantum_seed", "set_classical_seed", diff --git a/source/qdk_package/src/interpreter.rs b/source/qdk_package/src/interpreter.rs index 9505ab0a981..ac0390de7df 100644 --- a/source/qdk_package/src/interpreter.rs +++ b/source/qdk_package/src/interpreter.rs @@ -42,7 +42,7 @@ use pyo3::{ IntoPyObjectExt, create_exception, exceptions::{PyException, PyValueError}, prelude::*, - types::{PyDict, PyList, PyString, PyTuple, PyType}, + types::{PyBool, PyDict, PyList, PyString, PyTuple, PyType}, }; use qsc::{ LanguageFeatures, PackageType, SourceMap, @@ -51,7 +51,7 @@ use qsc::{ fir::{self}, hir::ty::{Prim, Ty}, interpret::{ - self, CircuitEntryPoint, PauliNoise, SimType, TaggedItem, Value, + self, CircuitEntryPoint, PackageGlobal, PauliNoise, SimType, TaggedItem, Value, output::{Error, Receiver}, }, openqasm::{ @@ -491,8 +491,14 @@ impl Interpreter { if let Some(make_callable) = &make_callable { // Add any global callables from the user source as Python functions to the environment. let exported_items = interpreter.source_globals(); - for (namespace, name, val) in exported_items { - create_py_callable(py, make_callable, &namespace, &name, val)?; + for PackageGlobal { + namespace, + name, + value, + is_test, + } in exported_items + { + create_py_callable(py, make_callable, &namespace, &name, value, is_test)?; } } if let Some(make_class) = &make_class { @@ -540,8 +546,14 @@ impl Interpreter { // This is safe because either the callable will be replaced with itself or a new callable with the // same name will shadow the previous one, which is the expected behavior. let new_items = self.interpreter.user_globals(); - for (namespace, name, val) in new_items { - create_py_callable(py, make_callable, &namespace, &name, val)?; + for PackageGlobal { + namespace, + name, + value, + is_test, + } in new_items + { + create_py_callable(py, make_callable, &namespace, &name, value, is_test)?; } } if let Some(make_class) = &self.make_class { @@ -667,8 +679,14 @@ impl Interpreter { // This is safe because either the callable will be replaced with itself or a new callable with the // same name will shadow the previous one, which is the expected behavior. let new_items = self.interpreter.user_globals(); - for (namespace, name, val) in new_items { - create_py_callable(py, make_callable, &namespace, &name, val)?; + for PackageGlobal { + namespace, + name, + value, + is_test, + } in new_items + { + create_py_callable(py, make_callable, &namespace, &name, value, is_test)?; } } value_to_pyobj(&self.interpreter, py, &value) @@ -1582,6 +1600,7 @@ fn create_py_callable( namespace: &[Rc], name: &str, val: Value, + is_test: bool, ) -> PyResult<()> { if namespace.is_empty() && name.starts_with(".lambda") { // We don't want to bind auto-generated lambda callables. @@ -1592,6 +1611,7 @@ fn create_py_callable( Py::new(py, GlobalCallable::from(val)).expect("should be able to create callable"), // callable id PyList::new(py, namespace.iter().map(ToString::to_string))?, // namespace as string array PyString::new(py, name), // name of callable + PyBool::new(py, is_test), // whether the callable has the @Test attribute ); // Call into the Python layer to create the function wrapping the callable invocation. diff --git a/source/resource_estimator/src/counts/tests.rs b/source/resource_estimator/src/counts/tests.rs index 2df0e80ec9d..b510e50c9ed 100644 --- a/source/resource_estimator/src/counts/tests.rs +++ b/source/resource_estimator/src/counts/tests.rs @@ -9,7 +9,7 @@ use indoc::indoc; use miette::Report; use qsc::{ LanguageFeatures, PackageType, SourceMap, TargetCapabilityFlags, - interpret::{GenericReceiver, Interpreter, Value}, + interpret::{GenericReceiver, Interpreter, PackageGlobal, Value}, target::Profile, }; @@ -73,7 +73,13 @@ fn source_global(interpreter: &Interpreter, name: &str) -> Value { interpreter .source_globals() .into_iter() - .find_map(|(_, global_name, value)| (global_name.as_ref() == name).then_some(value)) + .find_map( + |PackageGlobal { + name: global_name, + value, + .. + }| (global_name.as_ref() == name).then_some(value), + ) .unwrap_or_else(|| panic!("{name} should be present in source globals")) } From d611cb28f8d737a15250d1eb5f9da232e994604c Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 10:13:58 -0700 Subject: [PATCH 2/8] run library tests as part of Python integration tests --- library/fixed_point/src/Operations.qs | 1 - source/compiler/qsc/src/interpret/tests.rs | 2 +- .../tests-integration/test_libraries.py | 17 +++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 source/qdk_package/tests-integration/test_libraries.py diff --git a/library/fixed_point/src/Operations.qs b/library/fixed_point/src/Operations.qs index 49f07c3f8ad..7d759576175 100644 --- a/library/fixed_point/src/Operations.qs +++ b/library/fixed_point/src/Operations.qs @@ -3,7 +3,6 @@ import Types.FixedPoint; import Init.PrepareFxP; -import Operations.AddFxP; import Signed.Operations.Invert2sSI, Signed.Operations.MultiplySI, Signed.Operations.SquareSI; import Facts.AssertPointPositionsIdenticalFxP; import Std.Arrays.Zipped; diff --git a/source/compiler/qsc/src/interpret/tests.rs b/source/compiler/qsc/src/interpret/tests.rs index 19c53d117a8..3b951e13546 100644 --- a/source/compiler/qsc/src/interpret/tests.rs +++ b/source/compiler/qsc/src/interpret/tests.rs @@ -2964,7 +2964,7 @@ mod given_interpreter { expect![[r#" "B" "#]] - .assert_debug_eq(&items[0].namespace); + .assert_debug_eq(&items[0].name); } #[test] diff --git a/source/qdk_package/tests-integration/test_libraries.py b/source/qdk_package/tests-integration/test_libraries.py new file mode 100644 index 00000000000..e43eb39b2cc --- /dev/null +++ b/source/qdk_package/tests-integration/test_libraries.py @@ -0,0 +1,17 @@ +import pytest +from qdk import Context + + +@pytest.mark.parametrize( + "library_name", + [ + "chemistry", + "fixed_point", + "qtest", + "rotations", + "signed", + "table_lookup", + ], +) +def test_library(library_name: str): + Context(project_root=f"library/{library_name}").run_tests() From 39a05a9453b98ecbd5726446568d031b027d47a0 Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 10:33:12 -0700 Subject: [PATCH 3/8] support regex --- source/qdk_package/qdk/_context.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/source/qdk_package/qdk/_context.py b/source/qdk_package/qdk/_context.py index eb7b32d0dba..0ead1dad9e2 100644 --- a/source/qdk_package/qdk/_context.py +++ b/source/qdk_package/qdk/_context.py @@ -10,6 +10,7 @@ """ import json +import re import sys import types import weakref @@ -1035,8 +1036,9 @@ def _get_test_callables(self) -> List[Callable]: """Returns a list of all Q# callables with the `@Test` attribute in this Context.""" test_callables: List[Callable] = [] - # Iterate through all the attributes of self.code and check if they are callables with the __is_test__ attribute set to True - # Recursively check for nested modules as well + # Iterate through all the attributes of self.code and check if they are + # callables with the __is_test__ attribute set to True. + # Recursively check for nested modules as well. def find_test_callables(module): for attr_name in dir(module): attr = getattr(module, attr_name) @@ -1050,13 +1052,25 @@ def find_test_callables(module): find_test_callables(self.code) return test_callables - def run_tests(self, seed: Optional[int] = None) -> None: + def run_tests( + self, + seed: Optional[int] = None, + regex: Optional[str] = None, + ) -> None: """ Runs all Q# callables with the `@Test` attribute in this Context. :param seed: The seed to use for the random number generator in simulation, if any. + :param regex: Optional regular expression used to filter tests by fully + qualified test name (for example, ``MyNamespace.MyTest``). Only + matching tests are run. """ tests = self._get_test_callables() + if regex is not None: + tests = [ + test for test in tests if re.search(regex, test.__name__) is not None + ] + print("Starting tests...") failed_tests = [] for test in tests: From 451f497298716ccb4f28c4b9ecc4368c7b4f7351 Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 11:13:54 -0700 Subject: [PATCH 4/8] add verbosity --- library/chemistry/src/Tests.qs | 2 +- source/qdk_package/qdk/_context.py | 50 +++++++++++++++---- .../tests-integration/test_libraries.py | 8 +++ 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/library/chemistry/src/Tests.qs b/library/chemistry/src/Tests.qs index e558af15e3c..357f81dc3fc 100644 --- a/library/chemistry/src/Tests.qs +++ b/library/chemistry/src/Tests.qs @@ -332,7 +332,7 @@ operation JordanWignerUCCDTermPQRSTest() : Unit { } @Config(Unrestricted) -// @Test() +@Test() operation JordanWignerUCCDTermPRQSTest() : Unit { let term0 = [NewJordanWignerInputState(2.0, 0.0, [2, 0, 4, 1])]; let state0 = [0., 0., 0., 0., 0.,-0.416147, 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.909297, 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]; diff --git a/source/qdk_package/qdk/_context.py b/source/qdk_package/qdk/_context.py index 0ead1dad9e2..83a2c45776c 100644 --- a/source/qdk_package/qdk/_context.py +++ b/source/qdk_package/qdk/_context.py @@ -1056,6 +1056,7 @@ def run_tests( self, seed: Optional[int] = None, regex: Optional[str] = None, + verbose: int = 1, ) -> None: """ Runs all Q# callables with the `@Test` attribute in this Context. @@ -1064,6 +1065,14 @@ def run_tests( :param regex: Optional regular expression used to filter tests by fully qualified test name (for example, ``MyNamespace.MyTest``). Only matching tests are run. + :param verbose: Verbosity level. + 0 - don't print anything. + 1 - print "." for each successful test case, suppress output from Q#. + 2 - print test case names, suppress output from Q#. + 3+ - print test case names and output from Q#. + For verbose>=1, prints failures and test summary in the end. + + Raises an error if some tests failed. """ tests = self._get_test_callables() if regex is not None: @@ -1071,19 +1080,38 @@ def run_tests( test for test in tests if re.search(regex, test.__name__) is not None ] - print("Starting tests...") + if verbose >= 1: + print(f"Running {len(tests)} tests...") failed_tests = [] + failures = [] for test in tests: - print(f"Running `{test.__name__}`...") + if verbose >= 2: + print(f"Running {test.__name__}...") try: - self.run(test, 1, seed=seed) - print(f"`{test.__name__}` passed") + self.run(test, 1, seed=seed, save_events=(verbose <= 2)) + if verbose == 1: + print(".", end="") + elif verbose >= 2: + print(f"PASSED: {test.__name__}") except Exception as e: - print(f"`{test.__name__}` FAILED with exception:\n{e}") + if verbose >= 1: + print() + print(f"FAILED: {test.__name__}") + print(e) failed_tests.append(test.__name__) - print( - f"Finished tests: {len(tests) - len(failed_tests)} passed, {len(failed_tests)} failed." - ) - if failed_tests: - for failed_test in failed_tests: - print(f" - `{failed_test}` FAILED") + failures.append(str(e)) + + # Print summary. + if verbose >= 1: + num_passed = len(tests) - len(failed_tests) + print() + print(f"Finished tests: {num_passed} passed, {len(failed_tests)} failed.") + for test_name in failed_tests: + print(f" FAILED: {test.__name__}") + + # Construct descriptive error if there are any failures. + if len(failed_tests) > 0: + err = f"{len(failed_tests)} test(s) failed\n" + for test_name, failure in zip(failed_tests, failures): + err += f"FAILED: {test.__name__}\n{failure}\n" + raise RuntimeError(err) diff --git a/source/qdk_package/tests-integration/test_libraries.py b/source/qdk_package/tests-integration/test_libraries.py index e43eb39b2cc..225f6d0a33b 100644 --- a/source/qdk_package/tests-integration/test_libraries.py +++ b/source/qdk_package/tests-integration/test_libraries.py @@ -15,3 +15,11 @@ ) def test_library(library_name: str): Context(project_root=f"library/{library_name}").run_tests() + + +# Use this test case for library development. +# Run with: +# pytest source/qdk_package/tests-integration/test_libraries.py::test_single -s +def test_single(): + ctx = Context(project_root="library/table_lookup") + ctx.run_tests(verbose=3, regex="TestLookupMatchesStd") From 98a2a873de78a58579c61f93d7df8fad21793f25 Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 11:24:51 -0700 Subject: [PATCH 5/8] move run_tests to test_utils --- source/qdk_package/qdk/_context.py | 85 -------------- source/qdk_package/qdk/_interpreter.py | 9 -- source/qdk_package/qdk/qsharp.py | 2 - source/qdk_package/qdk/test_utils.py | 111 ++++++++++++++++-- .../tests-integration/test_libraries.py | 5 +- 5 files changed, 104 insertions(+), 108 deletions(-) diff --git a/source/qdk_package/qdk/_context.py b/source/qdk_package/qdk/_context.py index 83a2c45776c..2adc8d4028e 100644 --- a/source/qdk_package/qdk/_context.py +++ b/source/qdk_package/qdk/_context.py @@ -10,7 +10,6 @@ """ import json -import re import sys import types import weakref @@ -1031,87 +1030,3 @@ def import_openqasm( def get_target_profile(self) -> TargetProfile: """Returns target profile for this Context.""" return self._target_profile - - def _get_test_callables(self) -> List[Callable]: - """Returns a list of all Q# callables with the `@Test` attribute in this Context.""" - test_callables: List[Callable] = [] - - # Iterate through all the attributes of self.code and check if they are - # callables with the __is_test__ attribute set to True. - # Recursively check for nested modules as well. - def find_test_callables(module): - for attr_name in dir(module): - attr = getattr(module, attr_name) - if callable(attr) and getattr(attr, "__is_test__", False): - test_callables.append(attr) - elif isinstance(attr, types.ModuleType) or isinstance( - attr, types.SimpleNamespace - ): - find_test_callables(attr) - - find_test_callables(self.code) - return test_callables - - def run_tests( - self, - seed: Optional[int] = None, - regex: Optional[str] = None, - verbose: int = 1, - ) -> None: - """ - Runs all Q# callables with the `@Test` attribute in this Context. - - :param seed: The seed to use for the random number generator in simulation, if any. - :param regex: Optional regular expression used to filter tests by fully - qualified test name (for example, ``MyNamespace.MyTest``). Only - matching tests are run. - :param verbose: Verbosity level. - 0 - don't print anything. - 1 - print "." for each successful test case, suppress output from Q#. - 2 - print test case names, suppress output from Q#. - 3+ - print test case names and output from Q#. - For verbose>=1, prints failures and test summary in the end. - - Raises an error if some tests failed. - """ - tests = self._get_test_callables() - if regex is not None: - tests = [ - test for test in tests if re.search(regex, test.__name__) is not None - ] - - if verbose >= 1: - print(f"Running {len(tests)} tests...") - failed_tests = [] - failures = [] - for test in tests: - if verbose >= 2: - print(f"Running {test.__name__}...") - try: - self.run(test, 1, seed=seed, save_events=(verbose <= 2)) - if verbose == 1: - print(".", end="") - elif verbose >= 2: - print(f"PASSED: {test.__name__}") - except Exception as e: - if verbose >= 1: - print() - print(f"FAILED: {test.__name__}") - print(e) - failed_tests.append(test.__name__) - failures.append(str(e)) - - # Print summary. - if verbose >= 1: - num_passed = len(tests) - len(failed_tests) - print() - print(f"Finished tests: {num_passed} passed, {len(failed_tests)} failed.") - for test_name in failed_tests: - print(f" FAILED: {test.__name__}") - - # Construct descriptive error if there are any failures. - if len(failed_tests) > 0: - err = f"{len(failed_tests)} test(s) failed\n" - for test_name, failure in zip(failed_tests, failures): - err += f"FAILED: {test.__name__}\n{failure}\n" - raise RuntimeError(err) diff --git a/source/qdk_package/qdk/_interpreter.py b/source/qdk_package/qdk/_interpreter.py index 89ad02ea2c4..8324259256b 100644 --- a/source/qdk_package/qdk/_interpreter.py +++ b/source/qdk_package/qdk/_interpreter.py @@ -456,15 +456,6 @@ def logical_counts( return _get_default_context().logical_counts(entry_expr, *args) -def run_tests(seed: Optional[int] = None) -> None: - """ - Runs all Q# callables with the `@Test` attribute in the current context. - - :param seed: The seed to use for the random number generator in simulation, if any. - """ - _get_default_context().run_tests(seed) - - def set_quantum_seed(seed: Optional[int]) -> None: """ Sets the seed for the random number generator used for quantum measurements. diff --git a/source/qdk_package/qdk/qsharp.py b/source/qdk_package/qdk/qsharp.py index 86a486a5161..a5d8d65e11a 100644 --- a/source/qdk_package/qdk/qsharp.py +++ b/source/qdk_package/qdk/qsharp.py @@ -49,7 +49,6 @@ circuit, estimate, logical_counts, - run_tests, set_quantum_seed, set_classical_seed, dump_machine, @@ -74,7 +73,6 @@ "circuit", "estimate", "logical_counts", - "run_tests", # Seed / state "set_quantum_seed", "set_classical_seed", diff --git a/source/qdk_package/qdk/test_utils.py b/source/qdk_package/qdk/test_utils.py index 02d83f86c75..aac234ae87c 100644 --- a/source/qdk_package/qdk/test_utils.py +++ b/source/qdk_package/qdk/test_utils.py @@ -1,8 +1,11 @@ -"""Helper functions for testing Q# code.""" +"""Utilities for testing Q# code.""" -from typing import Any +from collections.abc import Callable +import re +import types +from typing import Any, Optional -from qdk._interpreter import _get_context_or_default +from qdk._interpreter import _get_context_or_default, _get_default_context from ._context import Context @@ -13,7 +16,7 @@ def dump_operation_on_state( initial_state: list[float] | None = None, context: Context | None = None, ) -> list[complex]: - """Returns statevector after applying operation to the given state. + """Return the state vector after applying an operation to a given state. Uses big-endian convention for basis-state numbering. @@ -22,15 +25,15 @@ def dump_operation_on_state( a Q# callable. The callable must have signature ``(Qubit[] => Unit)``. num_qubits: Number of qubits the operation acts on. - initial_state: Initial state given by list of `2**num_qubits` real amplitudes. + initial_state: Initial state as a list of ``2**num_qubits`` real amplitudes. If the list is shorter, it will be padded with zeros. - If not provided, the initial state is zero state (|00..0>). - context: `qdk.Context` from which the operation was created (optional). If - not provided, will attempt to infer it from `op` and then fall back to - default context. + If not provided, the initial state is the zero state (|00..0>). + context: Optional ``qdk.Context`` in which to evaluate the operation. + If not provided, this function attempts to infer a context from ``op`` + and falls back to the default context. Returns: - The state vector as a list of `2**num_qubits` complex numbers. + The state vector as a list of ``2**num_qubits`` complex numbers. """ context = context or _get_context_or_default(op) if initial_state is None: @@ -68,3 +71,91 @@ def dump_operation_on_state( for index, amplitude in state.items(): statevector[index] = amplitude return statevector + + +def _get_test_callables(context: Context) -> list[Callable]: + """Return all Q# callables marked with the ``@Test`` attribute.""" + test_callables: list[Callable] = [] + + # Iterate through all the attributes of self.code and check if they are + # callables with the __is_test__ attribute set to True. + # Recursively check for nested modules as well. + def find_test_callables(module): + for attr_name in dir(module): + attr = getattr(module, attr_name) + if callable(attr) and getattr(attr, "__is_test__", False): + test_callables.append(attr) + elif isinstance(attr, types.ModuleType) or isinstance( + attr, types.SimpleNamespace + ): + find_test_callables(attr) + + find_test_callables(context.code) + return test_callables + + +def run_tests( + context: Optional[Context] = None, + seed: Optional[int] = None, + regex: Optional[str] = None, + verbose: int = 1, +) -> None: + """ + Discover and run ``@Test`` Q# callables in the selected context, with + optional name filtering and configurable verbosity. + + :param context: Optional `qdk.Context` to discover and run tests in. If not + provided, the default context is used. + :param seed: The seed to use for the random number generator in simulation, if any. + :param regex: Optional regular expression used to filter tests by fully + qualified test name (for example, ``MyNamespace.MyTest``). Only + matching tests are run. + :param verbose: Verbosity level. + 0 - Print nothing. + 1 - Print ``.`` for each successful test and suppress Q# output. + 2 - Print test names and suppress Q# output. + 3+ - Print test names and Q# output. + For ``verbose >= 1``, failures and a summary are printed at the end. + + :raises RuntimeError: If one or more tests fail. + """ + context = context or _get_default_context() + tests = _get_test_callables(context) + if regex is not None: + tests = [test for test in tests if re.search(regex, test.__name__) is not None] + + if verbose >= 1: + print(f"Running {len(tests)} tests...") + failed_tests = [] + failures = [] + for test in tests: + if verbose >= 2: + print(f"Running {test.__name__}...") + try: + context.run(test, 1, seed=seed, save_events=(verbose <= 2)) + if verbose == 1: + print(".", end="") + elif verbose >= 2: + print(f"PASSED: {test.__name__}") + except Exception as e: + if verbose >= 1: + print() + print(f"FAILED: {test.__name__}") + print(e) + failed_tests.append(test.__name__) + failures.append(str(e)) + + # Print summary. + if verbose >= 1: + num_passed = len(tests) - len(failed_tests) + print() + print(f"Finished tests: {num_passed} passed, {len(failed_tests)} failed.") + for test_name in failed_tests: + print(f" FAILED: {test.__name__}") + + # Construct descriptive error if there are any failures. + if len(failed_tests) > 0: + err = f"{len(failed_tests)} test(s) failed\n" + for test_name, failure in zip(failed_tests, failures): + err += f"FAILED: {test.__name__}\n{failure}\n" + raise RuntimeError(err) diff --git a/source/qdk_package/tests-integration/test_libraries.py b/source/qdk_package/tests-integration/test_libraries.py index 225f6d0a33b..29305987fca 100644 --- a/source/qdk_package/tests-integration/test_libraries.py +++ b/source/qdk_package/tests-integration/test_libraries.py @@ -1,5 +1,6 @@ import pytest from qdk import Context +from qdk.test_utils import run_tests @pytest.mark.parametrize( @@ -14,7 +15,7 @@ ], ) def test_library(library_name: str): - Context(project_root=f"library/{library_name}").run_tests() + run_tests(Context(project_root=f"library/{library_name}")) # Use this test case for library development. @@ -22,4 +23,4 @@ def test_library(library_name: str): # pytest source/qdk_package/tests-integration/test_libraries.py::test_single -s def test_single(): ctx = Context(project_root="library/table_lookup") - ctx.run_tests(verbose=3, regex="TestLookupMatchesStd") + run_tests(ctx, verbose=3, regex="TestLookupMatchesStd") From c32ffb1eb42fe1b728ee5f967d45e8c113ccb10b Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 11:30:28 -0700 Subject: [PATCH 6/8] organize imports --- source/qdk_package/qdk/test_utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/source/qdk_package/qdk/test_utils.py b/source/qdk_package/qdk/test_utils.py index aac234ae87c..b6359fb4990 100644 --- a/source/qdk_package/qdk/test_utils.py +++ b/source/qdk_package/qdk/test_utils.py @@ -1,13 +1,12 @@ """Utilities for testing Q# code.""" -from collections.abc import Callable import re import types +from collections.abc import Callable from typing import Any, Optional -from qdk._interpreter import _get_context_or_default, _get_default_context - from ._context import Context +from ._interpreter import _get_context_or_default, _get_default_context def dump_operation_on_state( From c4d1a3c985ba7bbce95b1c1c1c1705932978781f Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 11:43:28 -0700 Subject: [PATCH 7/8] locate library directory --- source/qdk_package/tests-integration/test_libraries.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/source/qdk_package/tests-integration/test_libraries.py b/source/qdk_package/tests-integration/test_libraries.py index 29305987fca..1e77cac27f3 100644 --- a/source/qdk_package/tests-integration/test_libraries.py +++ b/source/qdk_package/tests-integration/test_libraries.py @@ -1,7 +1,12 @@ +from pathlib import Path + import pytest from qdk import Context from qdk.test_utils import run_tests +# Directory with all Q# libraries. +_LIB_DIR = str(Path(__file__).resolve().parents[3] / "library") + @pytest.mark.parametrize( "library_name", @@ -15,12 +20,12 @@ ], ) def test_library(library_name: str): - run_tests(Context(project_root=f"library/{library_name}")) + run_tests(Context(project_root=f"{_LIB_DIR}/{library_name}")) # Use this test case for library development. # Run with: # pytest source/qdk_package/tests-integration/test_libraries.py::test_single -s def test_single(): - ctx = Context(project_root="library/table_lookup") + ctx = Context(project_root=f"{_LIB_DIR}/table_lookup") run_tests(ctx, verbose=3, regex="TestLookupMatchesStd") From fd69c64efadecd4160377948bc4f0b9654554075 Mon Sep 17 00:00:00 2001 From: Dima Fedoriaka Date: Tue, 7 Jul 2026 13:32:15 -0700 Subject: [PATCH 8/8] fix tests --- source/qdk_package/tests/test_interpreter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/qdk_package/tests/test_interpreter.py b/source/qdk_package/tests/test_interpreter.py index db704d6bfe0..f2e3197a46f 100644 --- a/source/qdk_package/tests/test_interpreter.py +++ b/source/qdk_package/tests/test_interpreter.py @@ -28,7 +28,7 @@ def check_invoke(source: str, callable: str, expect: str): e = None f = None - def _make_callable(callable, namespace, callable_name): + def _make_callable(callable, namespace, callable_name, _is_test): nonlocal f f = callable @@ -436,7 +436,7 @@ def test_estimate_from_udt_returning_callable_matches_logical_counts_on_base_pro ): counted = None - def make_callable(callable_value, _namespace, callable_name): + def make_callable(callable_value, _namespace, callable_name, _is_test): nonlocal counted if callable_name == "Counted": counted = callable_value