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
8 changes: 4 additions & 4 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
[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)
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
Expand Down
4 changes: 2 additions & 2 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions benches/hot_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|| {
Expand Down
3 changes: 2 additions & 1 deletion benches/plugin_overhead.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,3 +48,4 @@ services:
volumes:
cargo-registry:
cargo-git:
cargo-target:
43 changes: 43 additions & 0 deletions src/discovery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion src/discovery/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
201 changes: 199 additions & 2 deletions src/discovery/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1346,7 +1346,7 @@ fn parse_module_with_relative_path(abs_path: &Path, rel_path: &Path) -> Result<T
}
ast::Stmt::ClassDef(class) => {
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();
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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");
}
}