diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index c486d700..c679929b 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -94,6 +94,31 @@ describe("scanJvmTargets (Java)", () => { [16, "test", "a.b.OrderTest$Fixture", "inner", false], ]); }); + + it("treats the public void methods of a TestNG class-level @Test as tests", () => { + const src = [ + "package a.b;", // 0 + "import org.testng.annotations.*;", + '@Test(groups = "unit")', + "public class PriceTest {", // 3 + " @BeforeMethod public void setUp() {}", + " public void rounds() {}", // 5 + ' @Test(dataProvider = "rows")', + " public void parses(String row) {}", // 7 + " @DataProvider public Object[][] rows() { return null; }", + ' @DataProvider(name = "more") public void notATest() {}', + " void packagePrivate() {}", + " private void util() {}", + " public static void shared() {}", + " public int value() { return 1; }", + "}", + ].join("\n"); + expect(pick(src, "src/test/java/a/b/PriceTest.java")).toEqual([ + [3, "suite", "a.b.PriceTest", null, false], + [5, "test", "a.b.PriceTest", "rounds", false], + [7, "test", "a.b.PriceTest", "parses", false], + ]); + }); }); describe("scanJvmTargets (Kotlin)", () => { diff --git a/src/features/run/jvmTargets.ts b/src/features/run/jvmTargets.ts index 63df4b6c..e188b22e 100644 --- a/src/features/run/jvmTargets.ts +++ b/src/features/run/jvmTargets.ts @@ -89,6 +89,8 @@ function tokenize(code: string): Token[] { } const TEST_ANNOTATIONS = new Set(["Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"]); +/** TestNG methods that set up or feed tests; not tests themselves under a class-level `@Test`. */ +const TESTNG_NOT_TESTS = /^(Before|After)\w*$|^(DataProvider|Factory)$/; const TYPE_KEYWORDS = new Set(["class", "interface", "enum", "record", "object"]); const NOT_TYPES = new Set(["new", "return", "throw", "else", "case", "assert", "yield", "await", "this", "super"]); const KOTLIN_DECL = /^(fun|val|var|class|object|interface|enum|data|sealed|abstract|open|private|public|internal|@)$/; @@ -246,7 +248,16 @@ function scan(src: string, relPath: string, kotlin: boolean) { const modifiers: string[] = []; for (let k = i - 1; k >= 0 && !MEMBER_END.has(tokens[k]!.text); k--) modifiers.push(tokens[k]!.text); const owner = top(); - if (annotations.some((a) => TEST_ANNOTATIONS.has(a)) && owner) { + // TestNG `@Test` on a class (JUnit's is method-only): its public `void` methods are tests. + const classLevelTest = + !kotlin && + owner?.kind === "class" && + owner.annotations.includes("Test") && + prev === "void" && + modifiers.includes("public") && + !modifiers.includes("static") && + !annotations.some((a) => TESTNG_NOT_TESTS.test(a)); + if ((annotations.some((a) => TEST_ANNOTATIONS.has(a)) || classLevelTest) && owner) { const className = qualify(binaryName(frames)); out.push({ line: tokens[i]!.line, kind: "test", className, method: name }); for (let f = frames.length; f > 0; f--) { diff --git a/src/features/run/testing/comparison.test.ts b/src/features/run/testing/comparison.test.ts index c5d38102..5a502693 100644 --- a/src/features/run/testing/comparison.test.ts +++ b/src/features/run/testing/comparison.test.ts @@ -10,6 +10,13 @@ describe("parseComparison", () => { expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' }); }); + it("TestNG", () => { + expect(parseComparison(["java.lang.AssertionError: total expected [3] but found [2]"])).toEqual({ + expected: "3", + actual: "2", + }); + }); + it("kotlin.test", () => { expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({ expected: "1", diff --git a/src/features/run/testing/comparison.ts b/src/features/run/testing/comparison.ts index 20b4fc26..18df71f0 100644 --- a/src/features/run/testing/comparison.ts +++ b/src/features/run/testing/comparison.ts @@ -21,6 +21,8 @@ export function parseComparison(output: readonly string[]): Comparison | null { // JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: but was: `. if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text))) return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) }; + // TestNG `Assert.assertEquals`: `expected [x] but found [y]`. + if ((m = /expected \[([\s\S]*?)\] but found \[([\s\S]*?)\]/.exec(text))) return { expected: m[1]!, actual: m[2]! }; // kotlin.test: `Expected , actual .` if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! }; // Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected). diff --git a/src/features/run/testing/jvm.ts b/src/features/run/testing/jvm.ts index 8d442b53..41cd021f 100644 --- a/src/features/run/testing/jvm.ts +++ b/src/features/run/testing/jvm.ts @@ -21,7 +21,7 @@ const SUREFIRE_CLASS_RE = const SUREFIRE_TOTAL_RE = /^(?:\[\w+\]\s+)?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)\s*$/; // `at org.junit…`, also behind a class loader or module (`at app//org.junit…`, `at java.base@21/java.lang…`). const LIBRARY_FRAME_RE = - /^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/; + /^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|org\.testng|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/; /** The source position of a stack frame in the project's code, null for library frames. */ function frameLocation(line: string): TestLocation | null { diff --git a/src/features/run/testing/parsers.test.ts b/src/features/run/testing/parsers.test.ts index 93e74d96..50b435a4 100644 --- a/src/features/run/testing/parsers.test.ts +++ b/src/features/run/testing/parsers.test.ts @@ -471,6 +471,24 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2> expect(r.get("com.acme.UserTest")?.own).toBe("passed"); expect(r.summary).toMatchObject({ passed: 2, failed: 1 }); }); + + it("locates a TestNG failure in the test, past TestNG's own frames", () => { + const r = results( + jvmParser(), + `[INFO] Running com.acme.PriceTest +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.2 s <<< FAILURE! -- in com.acme.PriceTest +[ERROR] com.acme.PriceTest.rounds -- Time elapsed: 0.01 s <<< FAILURE! +java.lang.AssertionError: expected [3] but found [2] + at org.testng.Assert.fail(Assert.java:111) + at org.testng.Assert.assertEquals(Assert.java:1080) + at com.acme.PriceTest.rounds(PriceTest.java:12) +`, + ); + expect(leaf(r, "com.acme.PriceTest", "rounds")?.location).toMatchObject({ + path: "com/acme/PriceTest.java", + line: 12, + }); + }); }); describe("parser selection", () => {