diff --git a/__tests__/__outputs__/dotnet-nunit.md b/__tests__/__outputs__/dotnet-nunit.md new file mode 100644 index 0000000..5b1d9c7 --- /dev/null +++ b/__tests__/__outputs__/dotnet-nunit.md @@ -0,0 +1,28 @@ +![Tests failed](https://img.shields.io/badge/tests-3%20passed%2C%205%20failed%2C%201%20skipped-critical) +## ❌ fixtures/dotnet-nunit.xml +**9** tests were completed in **0ms** with **3** passed, **5** failed and **1** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[DotnetTests.NUnitV3Tests.dll.DotnetTests.XUnitTests](#r0s0)|3✅|5❌|1⚪|0ms| +### ❌ DotnetTests.NUnitV3Tests.dll.DotnetTests.XUnitTests +``` +CalculatorTests + ✅ Is_Even_Number(2) + ❌ Is_Even_Number(3) + Expected: True + But was: False + + ❌ Exception_In_TargetTest + System.DivideByZeroException : Attempted to divide by zero. + ❌ Exception_In_Test + System.Exception : Test + ❌ Failing_Test + Expected: 3 + But was: 2 + + ✅ Passing_Test + ✅ Passing_Test_With_Description + ⚪ Skipped_Test + ❌ Timeout_Test + +``` \ No newline at end of file diff --git a/__tests__/__snapshots__/dotnet-nunit.test.ts.snap b/__tests__/__snapshots__/dotnet-nunit.test.ts.snap new file mode 100644 index 0000000..203fcc4 --- /dev/null +++ b/__tests__/__snapshots__/dotnet-nunit.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dotnet-nunit tests report from ./reports/dotnet test results matches snapshot 1`] = ` +TestRunResult { + "path": "fixtures/dotnet-nunit.xml", + "suites": Array [ + TestSuiteResult { + "groups": Array [ + TestGroupResult { + "name": "CalculatorTests", + "tests": Array [ + TestCaseResult { + "error": undefined, + "name": "Is_Even_Number(2)", + "result": "success", + "time": 0.000622, + }, + TestCaseResult { + "error": Object { + "details": " at DotnetTests.XUnitTests.CalculatorTests.Is_Even_Number(Int32 i) in C:\\\\Users\\\\Michal\\\\Workspace\\\\dorny\\\\test-reporter\\\\reports\\\\dotnet\\\\DotnetTests.NUnitV3Tests\\\\CalculatorTests.cs:line 61 +", + "line": undefined, + "message": " Expected: True + But was: False +", + "path": undefined, + }, + "name": "Is_Even_Number(3)", + "result": "failed", + "time": 0.001098, + }, + TestCaseResult { + "error": Object { + "details": " at DotnetTests.Unit.Calculator.Div(Int32 a, Int32 b) in C:\\\\Users\\\\Michal\\\\Workspace\\\\dorny\\\\test-reporter\\\\reports\\\\dotnet\\\\DotnetTests.Unit\\\\Calculator.cs:line 9 + at DotnetTests.XUnitTests.CalculatorTests.Exception_In_TargetTest() in C:\\\\Users\\\\Michal\\\\Workspace\\\\dorny\\\\test-reporter\\\\reports\\\\dotnet\\\\DotnetTests.NUnitV3Tests\\\\CalculatorTests.cs:line 33", + "line": undefined, + "message": "System.DivideByZeroException : Attempted to divide by zero.", + "path": undefined, + }, + "name": "Exception_In_TargetTest", + "result": "failed", + "time": 0.022805, + }, + TestCaseResult { + "error": Object { + "details": " at DotnetTests.XUnitTests.CalculatorTests.Exception_In_Test() in C:\\\\Users\\\\Michal\\\\Workspace\\\\dorny\\\\test-reporter\\\\reports\\\\dotnet\\\\DotnetTests.NUnitV3Tests\\\\CalculatorTests.cs:line 39", + "line": undefined, + "message": "System.Exception : Test", + "path": undefined, + }, + "name": "Exception_In_Test", + "result": "failed", + "time": 0.000528, + }, + TestCaseResult { + "error": Object { + "details": " at DotnetTests.XUnitTests.CalculatorTests.Failing_Test() in C:\\\\Users\\\\Michal\\\\Workspace\\\\dorny\\\\test-reporter\\\\reports\\\\dotnet\\\\DotnetTests.NUnitV3Tests\\\\CalculatorTests.cs:line 27 +", + "line": undefined, + "message": " Expected: 3 + But was: 2 +", + "path": undefined, + }, + "name": "Failing_Test", + "result": "failed", + "time": 0.028162, + }, + TestCaseResult { + "error": undefined, + "name": "Passing_Test", + "result": "success", + "time": 0.000238, + }, + TestCaseResult { + "error": undefined, + "name": "Passing_Test_With_Description", + "result": "success", + "time": 0.000135, + }, + TestCaseResult { + "error": undefined, + "name": "Skipped_Test", + "result": "skipped", + "time": 0.000398, + }, + TestCaseResult { + "error": Object { + "details": "", + "line": undefined, + "message": "", + "path": undefined, + }, + "name": "Timeout_Test", + "result": "failed", + "time": 0.014949, + }, + ], + }, + ], + "name": "DotnetTests.NUnitV3Tests.dll.DotnetTests.XUnitTests", + "totalTime": undefined, + }, + ], + "totalTime": 0.230308, +} +`; diff --git a/__tests__/dotnet-nunit.test.ts b/__tests__/dotnet-nunit.test.ts new file mode 100644 index 0000000..731aeea --- /dev/null +++ b/__tests__/dotnet-nunit.test.ts @@ -0,0 +1,29 @@ +import * as fs from 'fs' +import * as path from 'path' + +import {DotNetNunitParser} from '../src/parsers/dotnet-nunit/dotnet-nunit-parser' +import {ParseOptions} from '../src/test-parser' +import {getReport} from '../src/report/get-report' +import {normalizeFilePath} from '../src/utils/path-utils' + +describe('dotnet-nunit tests', () => { + it('report from ./reports/dotnet test results matches snapshot', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'dotnet-nunit.xml') + const outputPath = path.join(__dirname, '__outputs__', 'dotnet-nunit.md') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: ['DotnetTests.Unit/Calculator.cs', 'DotnetTests.NUnitV3Tests/CalculatorTests.cs'] + } + + const parser = new DotNetNunitParser(opts) + const result = await parser.parse(filePath, fileContent) + expect(result).toMatchSnapshot() + + const report = getReport([result]) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, report) + }) +}) diff --git a/src/parsers/dotnet-nunit/dotnet-nunit-parser.ts b/src/parsers/dotnet-nunit/dotnet-nunit-parser.ts new file mode 100644 index 0000000..d4a5791 --- /dev/null +++ b/src/parsers/dotnet-nunit/dotnet-nunit-parser.ts @@ -0,0 +1,151 @@ +import {ParseOptions, TestParser} from '../../test-parser' +import {parseStringPromise} from 'xml2js' + +import {NunitReport, TestCase, TestRun, TestSuite} from './dotnet-nunit-types' +import {getExceptionSource} from '../../utils/node-utils' +import {getBasePath, normalizeFilePath} from '../../utils/path-utils' + +import { + TestExecutionResult, + TestRunResult, + TestSuiteResult, + TestGroupResult, + TestCaseResult, + TestCaseError +} from '../../test-results' + +export class DotNetNunitParser implements TestParser { + assumedWorkDir: string | undefined + + constructor(readonly options: ParseOptions) {} + + async parse(path: string, content: string): Promise { + const ju = await this.getNunitReport(path, content) + return this.getTestRunResult(path, ju) + } + + private async getNunitReport(path: string, content: string): Promise { + try { + return (await parseStringPromise(content)) as NunitReport + } catch (e) { + throw new Error(`Invalid XML at ${path}\n\n${e}`) + } + } + + private getTestRunResult(path: string, nunit: NunitReport): TestRunResult { + const suites: TestSuiteResult[] = [] + const time = parseFloat(nunit['test-run'].$.duration) + + this.populateTestCasesRecursive(suites, [], nunit['test-run']['test-suite']) + + return new TestRunResult(path, suites, time) + } + + private populateTestCasesRecursive( + result: TestSuiteResult[], + suitePath: TestSuite[], + testSuites: TestSuite[] | undefined + ): void { + if (testSuites === undefined) { + return + } + + testSuites.forEach(suite => { + suitePath.push(suite) + + this.populateTestCasesRecursive(result, suitePath, suite['test-suite']) + + const testcases = suite['test-case'] + if (testcases !== undefined) { + testcases.forEach(testcase => { + this.addTestCase(result, suitePath, testcase) + }) + } + + suitePath.pop() + }) + } + + private addTestCase(result: TestSuiteResult[], suitePath: TestSuite[], testCase: TestCase) { + // The last suite in the suite path is the "group". + // The rest are concatenated together to form the "suite". + // But ignore "Theory" suites. + const suitesWithoutTheories = suitePath.filter(suite => suite.$.type !== 'Theory') + const suiteName = suitesWithoutTheories + .slice(0, suitesWithoutTheories.length - 1) + .map(suite => suite.$.name) + .join('.') + const groupName = suitesWithoutTheories[suitesWithoutTheories.length - 1].$.name + + let existingSuite = result.find(existingSuite => existingSuite.name === suiteName) + if (existingSuite === undefined) { + existingSuite = new TestSuiteResult(suiteName, []) + result.push(existingSuite) + } + + let existingGroup = existingSuite.groups.find(existingGroup => existingGroup.name === groupName) + if (existingGroup === undefined) { + existingGroup = new TestGroupResult(groupName, []) + existingSuite.groups.push(existingGroup) + } + + existingGroup.tests.push( + new TestCaseResult( + testCase.$.name, + this.getTestExecutionResult(testCase), + parseFloat(testCase.$.duration), + this.getTestCaseError(testCase) + ) + ) + } + + private getTestExecutionResult(test: TestCase): TestExecutionResult { + if (test.$.result === 'Failed' || test.failure) return 'failed' + if (test.$.result === 'Skipped') return 'skipped' + return 'success' + } + + private getTestCaseError(tc: TestCase): TestCaseError | undefined { + if (!this.options.parseErrors || !tc.failure || tc.failure.length === 0) { + return undefined + } + + const details = tc.failure[0] + let path + let line + + if (details['stack-trace'] !== undefined && details['stack-trace'].length > 0) { + const src = getExceptionSource(details['stack-trace'][0], this.options.trackedFiles, file => + this.getRelativePath(file) + ) + if (src) { + path = src.path + line = src.line + } + } + + return { + path: path, + line: line, + message: details.message && details.message.length > 0 ? details.message[0] : '', + details: details['stack-trace'] && details['stack-trace'].length > 0 ? details['stack-trace'][0] : '' + } + } + + private getRelativePath(path: string): string { + path = normalizeFilePath(path) + const workDir = this.getWorkDir(path) + if (workDir !== undefined && path.startsWith(workDir)) { + path = path.substr(workDir.length) + } + return path + } + + private getWorkDir(path: string): string | undefined { + return ( + this.options.workDir ?? + this.assumedWorkDir ?? + (this.assumedWorkDir = getBasePath(path, this.options.trackedFiles)) + ) + } +} diff --git a/src/parsers/dotnet-nunit/dotnet-nunit-types.ts b/src/parsers/dotnet-nunit/dotnet-nunit-types.ts new file mode 100644 index 0000000..3ac76c1 --- /dev/null +++ b/src/parsers/dotnet-nunit/dotnet-nunit-types.ts @@ -0,0 +1,57 @@ +export interface NunitReport { + "test-run": TestRun +} + +export interface TestRun { + $: { + id: string + runstate: string + testcasecount: string + result: string + total: string + passed: string + failed: string + inconclusive: string + skipped: string + asserts: string + 'engine-version': string + 'clr-version': string + 'start-time': string + 'end-time': string + duration: string + } + 'test-suite'?: TestSuite[] +} + +export interface TestSuite { + $: { + name: string + type: string + } + 'test-case'?: TestCase[] + 'test-suite'?: TestSuite[] +} + +export interface TestCase { + $: { + id: string + name: string + fullname: string + methodname: string + classname: string + runstate: string + seed: string + result: string + label: string + 'start-time': string + 'end-time': string + duration: string + asserts: string + } + failure?: TestFailure[] +} + +export interface TestFailure { + message?: string[] + 'stack-trace'?: string[] +}