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 @@
+
+## ❌ 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[]
+}