Skip to content

Commit d7c1218

Browse files
committed
feat(junit5): add Java 21+ implicit class test detection
Fix @test methods not being detected in Java 21+ implicitly declared classes (JEP 445/463). The existing parseTestClasses function only traversed class_declaration nodes, missing methods defined directly at file level (implicit class). Since tree-sitter already parses correctly, added traversal logic for method_declaration nodes that are direct children of program. - Add implicit class method detection to parseTestClasses - Add getImplicitClassName helper: extract suite name fr fix #101
1 parent 3280998 commit d7c1218

File tree

3 files changed

+155
-2
lines changed

3 files changed

+155
-2
lines changed

pkg/parser/strategies/junit5/definition.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package junit5
44
import (
55
"context"
66
"fmt"
7+
"path/filepath"
78
"regexp"
89
"strings"
910

@@ -116,20 +117,45 @@ const maxNestedDepth = 20
116117

117118
func parseTestClasses(root *sitter.Node, source []byte, filename string) []domain.TestSuite {
118119
var suites []domain.TestSuite
120+
var implicitClassTests []domain.Test
119121

120122
parser.WalkTree(root, func(node *sitter.Node) bool {
121-
if node.Type() == javaast.NodeClassDeclaration {
123+
switch node.Type() {
124+
case javaast.NodeClassDeclaration:
122125
if suite := parseTestClassWithDepth(node, source, filename, 0); suite != nil {
123126
suites = append(suites, *suite)
124127
}
125128
return false // Don't recurse into nested classes here
129+
130+
case javaast.NodeMethodDeclaration:
131+
// Handle Java 21+ implicit classes: methods directly under program node
132+
if node.Parent() != nil && node.Parent().Type() == "program" {
133+
if test := parseTestMethod(node, source, filename, domain.TestStatusActive, ""); test != nil {
134+
implicitClassTests = append(implicitClassTests, *test)
135+
}
136+
}
126137
}
127138
return true
128139
})
129140

141+
// Create synthetic suite for implicit class tests (Java 21+)
142+
if len(implicitClassTests) > 0 {
143+
suites = append(suites, domain.TestSuite{
144+
Name: getImplicitClassName(filename),
145+
Status: domain.TestStatusActive,
146+
Location: parser.GetLocation(root, filename),
147+
Tests: implicitClassTests,
148+
})
149+
}
150+
130151
return suites
131152
}
132153

154+
// getImplicitClassName extracts class name from filename for Java 21+ implicit classes.
155+
func getImplicitClassName(filename string) string {
156+
return strings.TrimSuffix(filepath.Base(filename), ".java")
157+
}
158+
133159
func parseTestClassWithDepth(node *sitter.Node, source []byte, filename string, depth int) *domain.TestSuite {
134160
if depth > maxNestedDepth {
135161
return nil

pkg/parser/strategies/junit5/definition_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,3 +913,130 @@ class KotestSpec : StringSpec({
913913
}
914914
})
915915
}
916+
917+
func TestJUnit5Parser_ImplicitClass(t *testing.T) {
918+
p := &JUnit5Parser{}
919+
ctx := context.Background()
920+
921+
t.Run("Java 21+ implicit class with @Test methods", func(t *testing.T) {
922+
// Java 21+ allows methods at file level without explicit class declaration
923+
source := `
924+
import module org.junit.start;
925+
926+
void main() {
927+
JUnit.run();
928+
}
929+
930+
@Test
931+
void stringLength() {
932+
Assertions.assertEquals(11, "Hello JUnit".length());
933+
}
934+
935+
@Test
936+
void anotherTest() {
937+
Assertions.assertTrue(true);
938+
}
939+
`
940+
testFile, err := p.Parse(ctx, []byte(source), "HelloTests.java")
941+
if err != nil {
942+
t.Fatalf("unexpected error: %v", err)
943+
}
944+
945+
if testFile.Path != "HelloTests.java" {
946+
t.Errorf("expected Path='HelloTests.java', got '%s'", testFile.Path)
947+
}
948+
if testFile.Framework != "junit5" {
949+
t.Errorf("expected Framework='junit5', got '%s'", testFile.Framework)
950+
}
951+
952+
if len(testFile.Suites) != 1 {
953+
t.Fatalf("expected 1 Suite for implicit class, got %d", len(testFile.Suites))
954+
}
955+
956+
suite := testFile.Suites[0]
957+
if suite.Name != "HelloTests" {
958+
t.Errorf("expected Suite.Name='HelloTests', got '%s'", suite.Name)
959+
}
960+
if len(suite.Tests) != 2 {
961+
t.Fatalf("expected 2 Tests in implicit class, got %d", len(suite.Tests))
962+
}
963+
if suite.Tests[0].Name != "stringLength" {
964+
t.Errorf("expected Tests[0].Name='stringLength', got '%s'", suite.Tests[0].Name)
965+
}
966+
if suite.Tests[1].Name != "anotherTest" {
967+
t.Errorf("expected Tests[1].Name='anotherTest', got '%s'", suite.Tests[1].Name)
968+
}
969+
})
970+
971+
t.Run("implicit class with path in filename", func(t *testing.T) {
972+
source := `
973+
@Test
974+
void singleTest() {}
975+
`
976+
testFile, err := p.Parse(ctx, []byte(source), "src/test/java/com/example/ImplicitTests.java")
977+
if err != nil {
978+
t.Fatalf("unexpected error: %v", err)
979+
}
980+
981+
if len(testFile.Suites) != 1 {
982+
t.Fatalf("expected 1 Suite, got %d", len(testFile.Suites))
983+
}
984+
985+
suite := testFile.Suites[0]
986+
// Should extract just the filename without path
987+
if suite.Name != "ImplicitTests" {
988+
t.Errorf("expected Suite.Name='ImplicitTests', got '%s'", suite.Name)
989+
}
990+
})
991+
992+
t.Run("mixed explicit class and implicit methods", func(t *testing.T) {
993+
// If a file has both a class and top-level methods, both should be detected
994+
source := `
995+
class ExplicitTest {
996+
@Test
997+
void classTest() {}
998+
}
999+
1000+
@Test
1001+
void implicitTest() {}
1002+
`
1003+
testFile, err := p.Parse(ctx, []byte(source), "MixedTests.java")
1004+
if err != nil {
1005+
t.Fatalf("unexpected error: %v", err)
1006+
}
1007+
1008+
if len(testFile.Suites) != 2 {
1009+
t.Fatalf("expected 2 Suites (explicit class + implicit class), got %d", len(testFile.Suites))
1010+
}
1011+
1012+
// First suite should be the explicit class
1013+
if testFile.Suites[0].Name != "ExplicitTest" {
1014+
t.Errorf("expected first Suite.Name='ExplicitTest', got '%s'", testFile.Suites[0].Name)
1015+
}
1016+
// Second suite should be the implicit class (filename-based)
1017+
if testFile.Suites[1].Name != "MixedTests" {
1018+
t.Errorf("expected second Suite.Name='MixedTests', got '%s'", testFile.Suites[1].Name)
1019+
}
1020+
})
1021+
}
1022+
1023+
func TestGetImplicitClassName(t *testing.T) {
1024+
tests := []struct {
1025+
filename string
1026+
expected string
1027+
}{
1028+
{"HelloTests.java", "HelloTests"},
1029+
{"src/test/java/HelloTests.java", "HelloTests"},
1030+
{"Test.java", "Test"},
1031+
{"MyTest", "MyTest"}, // no extension
1032+
}
1033+
1034+
for _, tt := range tests {
1035+
t.Run(tt.filename, func(t *testing.T) {
1036+
result := getImplicitClassName(tt.filename)
1037+
if result != tt.expected {
1038+
t.Errorf("expected '%s', got '%s'", tt.expected, result)
1039+
}
1040+
})
1041+
}
1042+
}

tests/integration/testdata/golden/junit5-samples-main.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"ref": "main",
44
"expectedFrameworks": ["junit4", "junit5", "kotest", "testng"],
55
"fileCount": 28,
6-
"testCount": 48,
6+
"testCount": 49,
77
"frameworkCounts": {
88
"junit4": 4,
99
"junit5": 22,

0 commit comments

Comments
 (0)