diff --git a/.cargo/config.toml b/.cargo/config.toml index 57b3516..40028cd 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,8 +4,8 @@ [build] # Use sccache for compilation caching rustc-wrapper = "sccache" -# Parallel jobs (adjust based on RAM) -jobs = 4 +# Parallel jobs (capped at 8 to limit SSD/CPU pressure) +jobs = 8 [target.x86_64-unknown-linux-gnu] # Use mold linker (10x faster than default ld) @@ -13,8 +13,8 @@ linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=mold"] [env] -# Parallel test threads -RUST_TEST_THREADS = "4" +# Parallel test threads (for cargo test, nextest uses its own config) +RUST_TEST_THREADS = "8" [profile.dev] incremental = true diff --git a/.config/nextest.toml b/.config/nextest.toml index a72d599..a53a2ed 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,8 +1,8 @@ # Nextest configuration for fast parallel testing [profile.default] -# Run tests in parallel -test-threads = "num-cpus" +# Run tests in parallel (capped at 8) +test-threads = 8 # Fail fast on first failure fail-fast = true # Retry flaky tests once diff --git a/Dockerfile b/Dockerfile index 6e49f60..4057447 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,9 +53,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ # Add cargo to PATH for all shells ENV PATH="/root/.cargo/bin:${PATH}" -# Install sccache and nextest for fast builds and test execution -RUN cargo install sccache --locked \ - && cargo install cargo-nextest --locked +# Install sccache and nextest (pre-built binaries, not from source) +RUN ARCH=$(uname -m) \ + && curl -fsSL "https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-${ARCH}-unknown-linux-musl.tar.gz" \ + | tar xz --strip-components=1 -C /usr/local/bin/ "sccache-v0.10.0-${ARCH}-unknown-linux-musl/sccache" \ + && curl -fsSL "https://get.nexte.st/latest/linux" | tar xz -C /usr/local/bin/ # Create workspace directory WORKDIR /workspace diff --git a/benches/hot_paths.rs b/benches/hot_paths.rs index 9f370a0..68e1d16 100644 --- a/benches/hot_paths.rs +++ b/benches/hot_paths.rs @@ -5,15 +5,15 @@ //! //! Results are stored in target/criterion/ for historical comparison. -use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use std::hint::black_box as hint_black_box; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use std::hint::black_box; /// Baseline benchmark to verify criterion is working correctly. /// This provides a reference point for timing measurements. fn bench_baseline(c: &mut Criterion) { let mut group = c.benchmark_group("baseline"); - group.bench_function("noop", |b| b.iter(|| hint_black_box(42))); + group.bench_function("noop", |b| b.iter(|| black_box(42))); group.bench_function("sum_1000", |b| { b.iter(|| { diff --git a/benches/plugin_overhead.rs b/benches/plugin_overhead.rs index 691376a..f171458 100644 --- a/benches/plugin_overhead.rs +++ b/benches/plugin_overhead.rs @@ -7,7 +7,8 @@ //! //! Run with: `cargo bench --bench plugin_overhead` -use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use std::hint::black_box; use std::path::PathBuf; use tach_core::hooks::{Hook, HookEffect, HookRegistry, HookSpec, LoopScope, SysPathAction}; diff --git a/docker-compose.yml b/docker-compose.yml index 0b4a9cd..34b5f6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ services: - cargo-registry:/root/.cargo/registry - cargo-git:/root/.cargo/git + # Build cache — survives container rebuilds + - cargo-target:/workspace/target + working_dir: /workspace # Keep container running for interactive use @@ -45,3 +48,4 @@ services: volumes: cargo-registry: cargo-git: + cargo-target: diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index f867280..be87da8 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -17,6 +17,49 @@ pub mod loader; pub mod resolver; pub mod scanner; +/// Check whether a class name follows pytest's test class naming conventions. +/// +/// Pytest discovers test classes through two mechanisms: +/// 1. **Name matching**: classes whose name starts with `Test` (the `python_classes` default) +/// 2. **Inheritance**: any `unittest.TestCase` subclass, regardless of name +/// +/// Since tach performs static AST analysis without import-time MRO resolution, +/// we approximate mechanism (2) by also matching common suffix conventions: +/// - `*Test` (e.g. `LoginTest`, `FormTest`) +/// - `*Tests` (e.g. `LoginTests`, `ModelFormTests`) +/// - `*TestCase` (e.g. `AutodiscoverModulesTestCase`, `MyFeatureTestCase`) +/// +/// The name must be at least 5 characters so that bare `"Test"` alone doesn't match — +/// a descriptive component is always required. +#[inline] +pub fn is_test_class(name: &str) -> bool { + let len = name.len(); + if len < 5 { + return false; + } + name.starts_with("Test") + || name.ends_with("Test") + || name.ends_with("Tests") + || name.ends_with("TestCase") +} + +/// Check whether any base class in the AST suggests a `unittest.TestCase` lineage. +/// +/// Inspects the class definition's `bases` list for names ending in `TestCase`, +/// matching patterns like `TestCase`, `unittest.TestCase`, and `django.test.TestCase`. +/// This catches test classes that don't follow pytest's `Test*` prefix convention +/// but inherit from `unittest.TestCase` (or any `*TestCase` base). +pub fn has_testcase_base(bases: &[rustpython_ast::Expr]) -> bool { + use rustpython_ast as ast; + bases.iter().any(|base| match base { + // Simple name: `class Foo(TestCase):` + ast::Expr::Name(name) => name.id.as_str().ends_with("TestCase"), + // Dotted name: `class Foo(unittest.TestCase):` or `class Foo(django.test.TestCase):` + ast::Expr::Attribute(attr) => attr.attr.as_str().ends_with("TestCase"), + _ => false, + }) +} + // Re-export main types from scanner for backward compatibility pub use scanner::{ DiscoveryResult, FixtureDefinition, FixtureScope, HookDefinition, MarkerInfo, TestCase, diff --git a/src/discovery/resolver.rs b/src/discovery/resolver.rs index 4c909c5..dd1ffb5 100644 --- a/src/discovery/resolver.rs +++ b/src/discovery/resolver.rs @@ -265,7 +265,6 @@ impl FixtureRegistry { // Check class-scoped fixtures first for tests in classes // Test names in classes have format "ClassName::method_name" if let Some(class_name) = test_name.split("::").next() - && class_name.starts_with("Test") && test_name.contains("::") { let key = (module_path.clone(), class_name.to_string()); diff --git a/src/discovery/scanner.rs b/src/discovery/scanner.rs index 3d61eeb..6a9d2ed 100644 --- a/src/discovery/scanner.rs +++ b/src/discovery/scanner.rs @@ -1346,7 +1346,7 @@ fn parse_module_with_relative_path(abs_path: &Path, rel_path: &Path) -> Result { let class_name = class.name.as_str(); - if class_name.starts_with("Test") { + if super::is_test_class(class_name) || super::has_testcase_base(&class.bases) { for stmt in &class.body { if let ast::Stmt::FunctionDef(func) = stmt { let method_name = func.name.as_str(); @@ -2086,7 +2086,7 @@ class MyClass: pass "#; let module = parse_source(source); - // Class doesn't start with "Test", so methods should be ignored + // Class doesn't match test naming conventions, so methods should be ignored assert!(module.tests.is_empty()); } @@ -2852,4 +2852,201 @@ def test_errors(exc): .any(|t| t.name == "test_errors[TimeoutError]") ); } + + // ========================================================================= + // Suffix-based test class discovery (Issue #80) + // ========================================================================= + + #[test] + fn test_parse_class_ending_with_test() { + let source = r#" +class LoginTest: + def test_valid_credentials(self): + pass + + def test_invalid_password(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 2); + assert!( + module + .tests + .iter() + .any(|t| t.name == "LoginTest::test_valid_credentials") + ); + assert!( + module + .tests + .iter() + .any(|t| t.name == "LoginTest::test_invalid_password") + ); + } + + #[test] + fn test_parse_class_ending_with_tests() { + let source = r#" +class ModelFormTests: + def test_form_valid(self): + pass + + def test_form_invalid(self, db): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 2); + assert!( + module + .tests + .iter() + .any(|t| t.name == "ModelFormTests::test_form_valid") + ); + assert!( + module + .tests + .iter() + .any(|t| t.name == "ModelFormTests::test_form_invalid") + ); + } + + #[test] + fn test_parse_suffix_class_excludes_non_test_methods() { + let source = r#" +class PermissionTests: + def test_access_denied(self): + pass + + def setUp(self): + pass + + def helper(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 1); + assert_eq!(module.tests[0].name, "PermissionTests::test_access_denied"); + } + + #[test] + fn test_parse_suffix_class_async_method() { + let source = r#" +class WebSocketTest: + async def test_connect(self, client): + await client.connect() +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 1); + assert_eq!(module.tests[0].name, "WebSocketTest::test_connect"); + assert!(module.tests[0].is_async); + assert_eq!(module.tests[0].dependencies, vec!["client"]); + } + + #[test] + fn test_is_test_class() { + use crate::discovery::is_test_class; + + assert!(is_test_class("TestLogin")); + assert!(is_test_class("TestModelForm")); + assert!(is_test_class("LoginTest")); + assert!(is_test_class("ModelFormTests")); + assert!(is_test_class("PermissionTests")); + assert!(is_test_class("AutodiscoverModulesTestCase")); + assert!(is_test_class("MyFeatureTestCase")); + assert!(is_test_class("JSONNormalizeTestCase")); + + assert!(!is_test_class("MyClass")); + assert!(!is_test_class("Helper")); + assert!(!is_test_class("Contest")); + assert!(!is_test_class("Fastest")); + assert!(!is_test_class("Test")); + assert!(is_test_class("Tests")); + assert!(!is_test_class("Tes")); + } + + // ========================================================================= + // Inheritance-Based Discovery (Base Class Detection) + // ========================================================================= + + #[test] + fn test_parse_unittest_testcase_class() { + let source = r#" +import unittest + +class DatabaseMigration(unittest.TestCase): + def test_migrate(self): + pass + + def test_rollback(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 2); + assert!( + module + .tests + .iter() + .any(|t| t.name == "DatabaseMigration::test_migrate") + ); + assert!( + module + .tests + .iter() + .any(|t| t.name == "DatabaseMigration::test_rollback") + ); + } + + #[test] + fn test_parse_plain_testcase_base() { + let source = r#" +from unittest import TestCase + +class MyValidation(TestCase): + def test_valid(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 1); + assert_eq!(module.tests[0].name, "MyValidation::test_valid"); + } + + #[test] + fn test_parse_django_testcase_base() { + let source = r#" +from django.test import TestCase + +class OrderViewPermissions(TestCase): + def test_anonymous(self): + pass + + def test_authenticated(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 2); + } + + #[test] + fn test_parse_no_test_in_name_no_testcase_base() { + let source = r#" +class MyHelper: + def test_something(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 0); + } + + #[test] + fn test_parse_custom_testcase_subclass() { + let source = r#" +from myproject.testing import AppTestCase + +class PaymentFlow(AppTestCase): + def test_checkout(self): + pass +"#; + let module = parse_source(source); + assert_eq!(module.tests.len(), 1); + assert_eq!(module.tests[0].name, "PaymentFlow::test_checkout"); + } }