diff --git a/docs/guide/reporters.md b/docs/guide/reporters.md index 4d3785fd1e73..db8cd32bfcf2 100644 --- a/docs/guide/reporters.md +++ b/docs/guide/reporters.md @@ -249,13 +249,17 @@ AssertionError: expected 5 to be 4 // Object.is equality ``` -The outputted XML contains nested `testsuites` and `testcase` tags. You can use the reporter options to configure these attributes: +The outputted XML contains nested `testsuites` and `testcase` tags. These can also be customized via reporter options `suiteName` and `classnameTemplate`. `classnameTemplate` can either be a template string or a function. + +The supported placeholders for the `classnameTemplate` option are: +- filename +- filepath ```ts export default defineConfig({ test: { reporters: [ - ['junit', { suiteName: 'custom suite name', classname: 'custom-classname' }] + ['junit', { suiteName: 'custom suite name', classnameTemplate: 'filename:{filename} - filepath:{filepath}' }] ] }, }) diff --git a/packages/vitest/src/node/reporters/junit.ts b/packages/vitest/src/node/reporters/junit.ts index c5e23b54174a..d6185813d133 100644 --- a/packages/vitest/src/node/reporters/junit.ts +++ b/packages/vitest/src/node/reporters/junit.ts @@ -11,9 +11,20 @@ import { getOutputFile } from '../../utils/config-helpers' import { capturePrintError } from '../error' import { IndentedLogger } from './renderers/indented-logger' +interface ClassnameTemplateVariables { + filename: string + filepath: string +} + export interface JUnitOptions { outputFile?: string + /** @deprecated Use `classnameTemplate` instead. */ classname?: string + + /** + * Template for the classname attribute. Can be either a string or a function. The string can contain placeholders {filename} and {filepath}. + */ + classnameTemplate?: string | ((classnameVariables: ClassnameTemplateVariables) => string) suiteName?: string /** * Write and for console output @@ -195,10 +206,29 @@ export class JUnitReporter implements Reporter { async writeTasks(tasks: Task[], filename: string): Promise { for (const task of tasks) { + let classname = filename + + const templateVars: ClassnameTemplateVariables = { + filename: task.file.name, + filepath: task.file.filepath, + } + + if (typeof this.options.classnameTemplate === 'function') { + classname = this.options.classnameTemplate(templateVars) + } + else if (typeof this.options.classnameTemplate === 'string') { + classname = this.options.classnameTemplate + .replace(/\{filename\}/g, templateVars.filename) + .replace(/\{filepath\}/g, templateVars.filepath) + } + else if (typeof this.options.classname === 'string') { + classname = this.options.classname + } + await this.writeElement( 'testcase', { - classname: this.options.classname ?? filename, + classname, file: this.options.addFileAttribute ? filename : undefined, name: task.name, time: getDuration(task), diff --git a/test/reporters/src/data.ts b/test/reporters/src/data.ts index c2b5b61fe788..786341eabb7e 100644 --- a/test/reporters/src/data.ts +++ b/test/reporters/src/data.ts @@ -25,6 +25,37 @@ const suite: Suite = { tasks: [], } +const passedFile: File = { + id: '1223128da3', + name: 'basic.test.ts', + type: 'suite', + suite, + meta: {}, + mode: 'run', + filepath: '/vitest/test/core/test/basic.test.ts', + result: { state: 'pass', duration: 145.99284195899963 }, + tasks: [ + ], + projectName: '', + file: null!, +} +passedFile.file = passedFile +passedFile.tasks.push({ + id: '1223128da3_0_0', + type: 'test', + name: 'Math.sqrt()', + mode: 'run', + fails: undefined, + suite, + meta: {}, + file: passedFile, + result: { + state: 'pass', + duration: 1.4422860145568848, + }, + context: null as any, +}) + const error: ErrorWithDiff = { name: 'AssertionError', message: 'expected 2.23606797749979 to equal 2', @@ -176,5 +207,6 @@ file.tasks = [suite] suite.tasks = tasks const files = [file] +const passedFiles = [passedFile] -export { files } +export { files, passedFiles } diff --git a/test/reporters/tests/__snapshots__/reporters.spec.ts.snap b/test/reporters/tests/__snapshots__/reporters.spec.ts.snap index 32d3fc3f7ab1..bf21bff29f27 100644 --- a/test/reporters/tests/__snapshots__/reporters.spec.ts.snap +++ b/test/reporters/tests/__snapshots__/reporters.spec.ts.snap @@ -14,6 +14,39 @@ exports[`JUnit reporter 1`] = ` " `; +exports[`JUnit reporter with custom function classnameTemplate 1`] = ` +" + + + + + + +" +`; + +exports[`JUnit reporter with custom string classname 1`] = ` +" + + + + + + +" +`; + +exports[`JUnit reporter with custom string classnameTemplate 1`] = ` +" + + + + + + +" +`; + exports[`JUnit reporter with outputFile 1`] = ` "JUNIT report written to /report.xml " @@ -62,6 +95,17 @@ exports[`JUnit reporter with outputFile object in non-existing directory 2`] = ` " `; +exports[`JUnit reporter without classname 1`] = ` +" + + + + + + +" +`; + exports[`json reporter (no outputFile entry) 1`] = ` { "numFailedTestSuites": 1, diff --git a/test/reporters/tests/reporters.spec.ts b/test/reporters/tests/reporters.spec.ts index 0b6a0d518ad8..b52ed51ce6d3 100644 --- a/test/reporters/tests/reporters.spec.ts +++ b/test/reporters/tests/reporters.spec.ts @@ -6,7 +6,7 @@ import { JUnitReporter } from '../../../packages/vitest/src/node/reporters/junit import { TapReporter } from '../../../packages/vitest/src/node/reporters/tap' import { TapFlatReporter } from '../../../packages/vitest/src/node/reporters/tap-flat' import { getContext } from '../src/context' -import { files } from '../src/data' +import { files, passedFiles } from '../src/data' const beautify = (json: string) => JSON.parse(json) @@ -60,6 +60,61 @@ test('JUnit reporter', async () => { expect(context.output).toMatchSnapshot() }) +test('JUnit reporter without classname', async () => { + // Arrange + const reporter = new JUnitReporter({}) + const context = getContext() + + // Act + await reporter.onInit(context.vitest) + + await reporter.onFinished(passedFiles) + + // Assert + expect(context.output).toMatchSnapshot() +}) + +test('JUnit reporter with custom string classname', async () => { + // Arrange + const reporter = new JUnitReporter({ classname: 'my-custom-classname' }) + const context = getContext() + + // Act + await reporter.onInit(context.vitest) + + await reporter.onFinished(passedFiles) + + // Assert + expect(context.output).toMatchSnapshot() +}) + +test('JUnit reporter with custom function classnameTemplate', async () => { + // Arrange + const reporter = new JUnitReporter({ classnameTemplate: task => `filename:${task.filename} - filepath:${task.filepath}` }) + const context = getContext() + + // Act + await reporter.onInit(context.vitest) + + await reporter.onFinished(passedFiles) + + // Assert + expect(context.output).toMatchSnapshot() +}) +test('JUnit reporter with custom string classnameTemplate', async () => { + // Arrange + const reporter = new JUnitReporter({ classnameTemplate: `filename:{filename} - filepath:{filepath}` }) + const context = getContext() + + // Act + await reporter.onInit(context.vitest) + + await reporter.onFinished(passedFiles) + + // Assert + expect(context.output).toMatchSnapshot() +}) + test('JUnit reporter (no outputFile entry)', async () => { // Arrange const reporter = new JUnitReporter({})