diff --git a/README.md b/README.md
index 683145b..8463aa8 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Parse test results from JUnit, TestNG, xUnit, Mocha(json), Cucumber(json) and ma
|-------------------------------|---------|
| TestNG | ✅ |
| JUnit | ✅ |
+| NUnit (v2 & v3) | ✅ |
| xUnit | ✅ |
| Mocha (json & mochawesome) | ✅ |
| Cucumber | ✅ |
\ No newline at end of file
diff --git a/src/helpers/helper.js b/src/helpers/helper.js
index 7416c0e..e9c80af 100644
--- a/src/helpers/helper.js
+++ b/src/helpers/helper.js
@@ -31,13 +31,26 @@ const FORCED_ARRAY_KEYS = [
"testng-results.suite.test",
"testng-results.suite.test.class",
"testng-results.suite.test.class.test-method",
- "testng-results.suite.test.class.test-method.exception",
+ "testng-results.suite.test.class.test-method.exception"
];
const configured_parser = new XMLParser({
isArray: (name, jpath, isLeafNode, isAttribute) => {
if( FORCED_ARRAY_KEYS.indexOf(jpath) !== -1) {
return true;
+ }
+ // handle nunit deep hierarchy
+ else if (jpath.startsWith("test-results") || jpath.startsWith("test-run")) {
+ let parts = jpath.split(".");
+ switch(parts[parts.length - 1]) {
+ case "category":
+ case "property":
+ case "test-suite":
+ case "test-case":
+ return true;
+ default:
+ return false;
+ }
}
},
ignoreAttributes: false,
diff --git a/src/parsers/index.js b/src/parsers/index.js
index 6cacfc4..edf4d6c 100644
--- a/src/parsers/index.js
+++ b/src/parsers/index.js
@@ -1,5 +1,6 @@
const testng = require('./testng');
const junit = require('./junit');
+const nunit = require('./nunit');
const xunit = require('./xunit');
const mocha = require('./mocha');
const cucumber = require('./cucumber');
@@ -37,6 +38,8 @@ function getParser(type) {
return junit;
case 'xunit':
return xunit;
+ case 'nunit':
+ return nunit;
case 'mocha':
return mocha;
case 'cucumber':
diff --git a/src/parsers/nunit.js b/src/parsers/nunit.js
new file mode 100644
index 0000000..0805480
--- /dev/null
+++ b/src/parsers/nunit.js
@@ -0,0 +1,219 @@
+const { getJsonFromXMLFile } = require('../helpers/helper');
+
+const TestResult = require('../models/TestResult');
+const TestSuite = require('../models/TestSuite');
+const TestCase = require('../models/TestCase');
+
+const SUITE_TYPES_WITH_TESTCASES = [
+ "TestFixture",
+ "ParameterizedTest",
+ "GenericFixture",
+ "ParameterizedMethod" // v3
+]
+
+const RESULTMAP = {
+ Success: "PASS", // v2
+ Failure: "FAIL", // v2
+ Ignored: "SKIP", // v2
+ NotRunnable: "SKIP", // v2
+ Error: "ERROR", // v2
+ Inconclusive: "FAIL", // v2
+
+ Passed: "PASS", // v3
+ Failed: "FAIL", // v3
+ Skipped: "SKIP", // v3
+}
+
+function mergeMeta(map1, map2) {
+ for(let kvp of map1) {
+ map2.set(kvp[0], kvp[1]);
+ }
+}
+
+function populateMetaData(raw, map) {
+
+ // v2 supports categories
+ if (raw.categories) {
+ let categories = raw.categories.category;
+ for (let i = 0; i < categories.length; i++) {
+ let categoryName = categories[i]["@_name"];
+ map.set(categoryName, "");
+
+ // create comma-delimited list of categories
+ if (map.has("Categories")) {
+ map.set("Categories", map.get("Categories").concat(",", categoryName));
+ } else {
+ map.set("Categories", categoryName);
+ }
+ }
+ }
+
+ // v2/v3 support properties
+ if (raw.properties) {
+ let properties = raw.properties.property;
+ for (let i = 0; i < properties.length; i++) {
+ let property = properties[i];
+ let propName = property["@_name"];
+ let propValue = property["@_value"];
+
+ // v3 treats 'Categories' as property "Category"
+ if (propName == "Category") {
+
+ if (map.has("Categories")) {
+ map.set("Categories", map.get("Categories").concat(",", propValue));
+ } else {
+ map.set("Categories", propValue);
+ }
+ map.set(propValue, "");
+
+ } else {
+ map.set(propName, propValue);
+ }
+ }
+ }
+}
+
+function getNestedTestCases(rawSuite) {
+ if (rawSuite.results) {
+ return rawSuite.results["test-case"];
+ } else {
+ return rawSuite["test-case"];
+ }
+}
+
+function hasNestedSuite(rawSuite) {
+ return getNestedSuite(rawSuite) !== null;
+}
+
+function getNestedSuite(rawSuite) {
+ // nunit v2 nests test-suite inside 'results'
+ if (rawSuite.results && rawSuite.results["test-suite"]) {
+ return rawSuite.results["test-suite"];
+ } else {
+ // nunit v3 nests test-suites as immediate children
+ if (rawSuite["test-suite"]) {
+ return rawSuite["test-suite"];
+ }
+ else {
+ // not nested
+ return null;
+ }
+ }
+}
+
+function getTestCases(rawSuite, parent_meta) {
+ var cases = [];
+
+ let rawTestCases = getNestedTestCases(rawSuite);
+ if (rawTestCases) {
+ for (let i = 0; i < rawTestCases.length; i++) {
+ let rawCase = rawTestCases[i];
+ let testCase = new TestCase();
+ let result = rawCase["@_result"]
+ testCase.id = rawCase["@_id"] ?? "";
+ testCase.name = rawCase["@_fullname"] ?? rawCase["@_name"];
+ testCase.duration = rawCase["@_time"] * 1000; // in milliseconds
+ testCase.status = RESULTMAP[result];
+
+ // v2 : non-executed should be tests should be Ignored
+ if (rawCase["@_executed"] == "False") {
+ testCase.status = "SKIP"; // exclude failures that weren't executed.
+ }
+ // v3 : failed tests with error label should be Error
+ if (rawCase["@_label"] == "Error") {
+ testCase.status = "ERROR";
+ }
+ let errorDetails = rawCase.reason ?? rawCase.failure;
+ if (errorDetails !== undefined) {
+ testCase.setFailure(errorDetails.message);
+ if (errorDetails["stack-trace"]) {
+ testCase.stack_trace = errorDetails["stack-trace"]
+ }
+ }
+ // copy parent_meta data to test case
+ mergeMeta(parent_meta, testCase.meta_data);
+ populateMetaData(rawCase, testCase.meta_data);
+
+ cases.push( testCase );
+ }
+ }
+
+ return cases;
+}
+
+function getTestSuites(rawSuites, assembly_meta) {
+ var suites = [];
+
+ for(let i = 0; i < rawSuites.length; i++) {
+ let rawSuite = rawSuites[i];
+
+ if (rawSuite["@_type"] == "Assembly") {
+ assembly_meta = new Map();
+ populateMetaData(rawSuite, assembly_meta);
+ }
+
+ if (hasNestedSuite(rawSuite)) {
+ // handle nested test-suites
+ suites.push(...getTestSuites(getNestedSuite(rawSuite), assembly_meta));
+ } else if (SUITE_TYPES_WITH_TESTCASES.indexOf(rawSuite["@_type"]) !== -1) {
+
+ let suite = new TestSuite();
+ suite.id = rawSuite["@_id"] ?? '';
+ suite.name = rawSuite["@_fullname"] ?? rawSuite["@_name"];
+ suite.duration = rawSuite["@_time"] * 1000; // in milliseconds
+ suite.status = RESULTMAP[rawSuite["@_result"]];
+
+ var meta_data = new Map();
+ mergeMeta(assembly_meta, meta_data);
+ populateMetaData(rawSuite, meta_data);
+ suite.cases.push(...getTestCases(rawSuite, meta_data));
+
+ // calculate totals
+ suite.total = suite.cases.length;
+ suite.passed = suite.cases.filter(i => i.status == "PASS").length;
+ suite.failed = suite.cases.filter(i => i.status == "FAIL").length;
+ suite.errors = suite.cases.filter(i => i.status == "ERROR").length;
+ suite.skipped = suite.cases.filter(i => i.status == "SKIP").length;
+
+ suites.push(suite);
+ }
+ }
+
+ return suites;
+}
+
+
+function getTestResult(json) {
+ const nunitVersion = (json["test-results"] !== undefined) ? "v2" :
+ (json["test-run"] !== undefined) ? "v3" : null;
+
+ if (nunitVersion == null) {
+ throw new Error("Unrecognized xml format");
+ }
+
+ const result = new TestResult();
+ const rawResult = json["test-results"] ?? json["test-run"];
+ const rawSuite = rawResult["test-suite"][0];
+
+ result.name = rawResult["@_fullname"] ?? rawResult["@_name"];
+ result.duration = rawSuite["@_time"] * 1000; // in milliseconds
+
+ result.suites.push(...getTestSuites( [ rawSuite ], null));
+
+ result.total = result.suites.reduce( (total, suite) => { return total + suite.cases.length}, 0);
+ result.passed = result.suites.reduce( (total, suite) => { return total + suite.passed}, 0);
+ result.failed = result.suites.reduce( (total, suite) => { return total + suite.failed}, 0);
+ result.skipped = result.suites.reduce( (total, suite) => { return total + suite.skipped}, 0);
+ result.errors = result.suites.reduce( (total, suite) => { return total + suite.errors}, 0);
+
+ return result;
+}
+
+function parse(file) {
+ const json = getJsonFromXMLFile(file);
+ return getTestResult(json);
+}
+
+module.exports = {
+ parse
+}
\ No newline at end of file
diff --git a/tests/data/nunit/nunit_v2.xml b/tests/data/nunit/nunit_v2.xml
new file mode 100644
index 0000000..7a645c6
--- /dev/null
+++ b/tests/data/nunit/nunit_v2.xml
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/data/nunit/nunit_v3.xml b/tests/data/nunit/nunit_v3.xml
new file mode 100644
index 0000000..d0d1712
--- /dev/null
+++ b/tests/data/nunit/nunit_v3.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/parser.nunit.spec.js b/tests/parser.nunit.spec.js
new file mode 100644
index 0000000..a3d8486
--- /dev/null
+++ b/tests/parser.nunit.spec.js
@@ -0,0 +1,261 @@
+const { parse } = require('../src');
+const assert = require('assert');
+const path = require('path');
+
+describe('Parser - NUnit', () => {
+
+ const testDataPath = "tests/data/nunit";
+ var result;
+
+ context('NUnit V2', () => {
+
+ before( () => {
+ result = parse({ type: 'nunit', files: [`${testDataPath}/nunit_v2.xml`] });
+ });
+
+ it('Should calculate totals', () => {
+ // evaluate totals on the testresult
+ assert.equal(result.total, 28);
+ assert.equal(result.passed, 18);
+ assert.equal(result.failed, 2); // include inconclusive as a failure
+ assert.equal(result.errors, 1);
+ assert.equal(result.skipped, 7);
+ assert.equal(result.total - (result.passed + result.failed + result.errors + result.skipped), 0);
+
+ // compare sum of suite totals to testresult
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.total} ,0), result.total);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.passed} ,0), result.passed);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.failed} ,0), result.failed);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.errors} ,0), result.errors);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.skipped} ,0), result.skipped);
+
+ // compare sum results of test cases to testresult totals
+ assert.equal( sumCases(result, (testCase) => { return 1 /* count of testcase */ }), result.total);
+ assert.equal( sumCases(result, (testCase) => { return testCase.status == "PASS" ? 1 : 0 }), result.passed);
+ assert.equal( sumCases(result, (testCase) => { return testCase.status == "FAIL" ? 1 : 0 }), result.failed);
+ assert.equal( sumCases(result, (testCase) => { return testCase.status == "ERROR" }), result.errors);
+ assert.equal( sumCases(result, (testCase) => { return testCase.status == "SKIP" ? 1 : 0 }), result.skipped);
+ });
+
+ it('Should express durations in milliseconds', () => {
+ let totalDuration = result.duration;
+ let suiteDuration = result.suites[0].duration; // sample
+ let testDuration = result.suites[0].cases[0].duration; // sample
+ assert.equal(totalDuration > 100, true, `TestResult duration should be more than 100 millisecond (${totalDuration}`);
+ assert.equal(suiteDuration > 100, true, `Suite duration should be more than 100 milliseconds (${suiteDuration})`);
+ assert.equal(testDuration > 10, true, `Test duration should be more than 10 milliseconds (${testDuration})`);
+ });
+
+ it('Should include names for all tests', () => {
+ let count = 0;
+ result.suites.forEach( s => {
+ s.cases.forEach( c => {
+ count++;
+ assert.equal( c.name !== '' && c.name !== undefined, true);
+ });
+ });
+ assert.equal( count > 0, true, "Should have evaluated multiple test cases.");
+ });
+
+ it('Should map results correctly', () => {
+ assert.equal(result.suites[0].status, "FAIL");
+ assert.equal(result.suites[0].cases[0].status, "FAIL"); // Failure
+ assert.equal(result.suites[0].cases[8].status, "ERROR"); // Error
+ assert.equal(result.suites[1].status, "SKIP");
+ assert.equal(result.suites[1].cases[0].status, "SKIP"); // NotRunnable
+ assert.equal(result.suites[2].status, "PASS");
+ assert.equal(result.suites[2].cases[0].status, "PASS"); // Success
+ assert.equal(result.suites[6].status, "SKIP");
+ assert.equal(result.suites[6].cases[0].status, "SKIP"); // Ignored
+ });
+
+ it('Should get reason for inconclusive', () => {
+ assert.equal(result.suites[0].cases[1].status, 'FAIL');
+ assert.notEqual(result.suites[0].cases[1].failure, undefined);
+ });
+
+ it('Should get failure message and stack trace for failure', () => {
+ assert.equal(result.suites[0].cases[0].status, 'FAIL');
+ assert.notEqual(result.suites[0].cases[0].failure, '');
+ assert.notEqual(result.suites[0].cases[0].stack_trace, '');
+ });
+
+ it('Should map suite categories to testcases', () => {
+ let count = 0;
+ result.suites[0].cases.forEach( c => {
+ count++;
+ assert.equal(c.meta_data.has("FixtureCategory"), true);
+ assert.equal(c.meta_data.get("Categories").includes("FixtureCategory"), true);
+ });
+ assert.equal( count > 0, true);
+ });
+
+ it('Should map test-case categories and properties to testcases', () => {
+ // case 3 has additional category + properties
+ let testcase = result.suites[0].cases[3];
+ // categories
+ assert.equal(testcase.meta_data.has("MockCategory"), true);
+ assert.equal(testcase.meta_data.get("Categories").includes("MockCategory"), true);
+ assert.equal(testcase.meta_data.get("Categories"), "FixtureCategory,MockCategory"); // combined
+
+ // properties
+ assert.equal(testcase.meta_data.get("Severity"),"Critical");
+ });
+
+ });
+
+ context('NUnit V3', () => {
+ before( () => {
+ result = parse({ type: 'nunit', files: [`${testDataPath}/nunit_v3.xml`] });
+ });
+
+ it('Should calculate totals', () => {
+ // totals on the testresult
+ assert.equal(result.total, 18);
+ assert.equal(result.passed, 12);
+ assert.equal(result.failed, 2);
+ assert.equal(result.errors, 1);
+
+ // compare sum of suite totals to testresult
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.total},0), result.total);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.passed},0), result.passed);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.failed},0), result.failed);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.errors},0), result.errors);
+ assert.equal( result.suites.reduce( (total, suite) => { return total + suite.skipped},0), result.skipped);
+ });
+
+ it('Should express durations in milliseconds', () => {
+ let totalDuration = result.duration;
+ let suiteDuration = result.suites[0].duration; // sample
+ let testDuration = result.suites[0].cases[0].duration; // sample
+ assert.equal(totalDuration > 100, true, `TestResult duration should be more than 100 millisecond (${totalDuration}`);
+ assert.equal(suiteDuration > 100, true, `Suite duration should be more than 100 milliseconds (${suiteDuration})`);
+ assert.equal(testDuration > 10, true, `Test duration should be more than 10 milliseconds (${testDuration})`);
+ });
+
+ it('Should include fullnames for testsuites and testcases', () => {
+ assert.equal(result.suites[0].name, "NUnit.Tests.Assemblies.MockTestFixture");
+ assert.equal(result.suites[0].cases[0].name, "NUnit.Tests.Assemblies.MockTestFixture.FailingTest")
+ });
+
+ it('Should map results correctly', () => {
+ assert.equal(result.suites.length, 8);
+
+ // assemblies.mocktestfixture
+ assert.equal(result.suites[0].status, "FAIL");
+ assert.equal(result.suites[0].cases[0].status, "FAIL");
+ assert.equal(result.suites[0].cases[1].status, "FAIL"); // inconclusive
+ assert.equal(result.suites[0].cases[2].status, "PASS");
+ assert.equal(result.suites[0].cases[3].status, "PASS");
+ assert.equal(result.suites[0].cases[4].status, "PASS");
+ assert.equal(result.suites[0].cases[5].status, "SKIP"); // ignored
+ assert.equal(result.suites[0].cases[6].status, "SKIP"); // invalid
+ assert.equal(result.suites[0].cases[7].status, "SKIP"); // invalid
+ assert.equal(result.suites[0].cases[8].status, "ERROR"); // error
+ assert.equal(result.suites[0].cases[9].status, "PASS");
+
+ // badfixture
+ assert.equal(result.suites[1].cases.length, 0); // invalid test cases
+ assert.equal(result.suites[1].status, "SKIP"); // v3 treats these as skipped
+
+ // fixturewithTestCases
+ assert.equal(result.suites[2].status, "PASS");
+ assert.equal(result.suites[4].cases[0].status, "PASS");
+ assert.equal(result.suites[4].cases[1].status, "PASS");
+
+ // ignoredfixture
+ assert.equal(result.suites[3].cases.length, 0); // invalid test cases
+ assert.equal(result.suites[3].status, "SKIP"); // v3 treats these as skipped
+
+ // parameterizedfixture(42)
+ assert.equal(result.suites[4].status, "PASS");
+ assert.equal(result.suites[4].cases[0].name, "NUnit.Tests.ParameterizedFixture(42).Test1");
+ assert.equal(result.suites[4].cases[0].status, "PASS");
+ assert.equal(result.suites[4].cases[1].status, "PASS");
+
+ // parameterizedfixture(5)
+ assert.equal(result.suites[5].status, "PASS");
+ assert.equal(result.suites[5].cases[0].name, "NUnit.Tests.ParameterizedFixture(5).Test1");
+ assert.equal(result.suites[5].cases[0].status, "PASS");
+ assert.equal(result.suites[5].cases[1].status, "PASS");
+
+ // onetestcase
+ assert.equal(result.suites[6].status, "PASS");
+ assert.equal(result.suites[6].cases[0].status, "PASS");
+
+ // testassembly.mocktestfixture
+ assert.equal(result.suites[7].status, "PASS");
+ assert.equal(result.suites[7].cases[0].status, "PASS");
+ });
+
+ it('Should include reason for invalid tests', () => {
+ // test-case 1009 has a reason for being invalid.
+ const testcase = result.suites[0].cases[7];
+ assert.equal(testcase.failure, "No arguments were provided");
+ });
+
+ it('Should include stack trace for failed tests.', () => {
+ const testcase = result.suites[0].cases[8];
+ assert.equal(testcase.failure, "System.ApplicationException : Intentional Exception");
+ assert.notEqual(testcase.stack_trace, '');
+ });
+
+ it('Should support properties defined at the Assembly level', () => {
+ const testCaseWithNoProperties = result.suites[2].cases[0].meta_data;
+ assert.equal(testCaseWithNoProperties.size, 2, "Test case without properties should inherit from assembly");
+ assert.equal(testCaseWithNoProperties.has("_PID"), true);
+ assert.equal(testCaseWithNoProperties.has("_APPDOMAIN"), true);
+ });
+
+ it('Should include both suite and assembly level properties', () => {
+ const testCaseWithSuiteProperties = result.suites[0].cases[0].meta_data
+ assert.equal(testCaseWithSuiteProperties.size, 5, "Suite with properties should inherit assembly properties");
+ assert.equal(testCaseWithSuiteProperties.has("_PID"), true);
+ assert.equal(testCaseWithSuiteProperties.has("_APPDOMAIN"), true);
+ assert.equal(testCaseWithSuiteProperties.has("Description"), true);
+ assert.equal(testCaseWithSuiteProperties.get("Description"), "Fake Test Fixture");
+ });
+
+ it('Should include properties from assembly, suite and test case', () => {
+
+ const testCaseWithProperties = result.suites[0].cases[2].meta_data;
+ assert.equal(testCaseWithProperties.size, 5, "Test case with properties should iherit assembly, suite and override");
+ assert.equal(testCaseWithProperties.has("_PID"), true);
+ assert.equal(testCaseWithProperties.has("_APPDOMAIN"), true);
+ assert.equal(testCaseWithProperties.get("Description"), "Mock Test #1");
+ });
+
+ it('Should allow multiple categories to be specified', () => {
+ const testCaseWithMultipleCategories = result.suites[0].cases[3].meta_data;
+ assert.equal(testCaseWithMultipleCategories.has("Categories"), true);
+ assert.equal(testCaseWithMultipleCategories.get("Categories"), "FixtureCategory,MockCategory");
+ assert.equal(testCaseWithMultipleCategories.has("FixtureCategory"), true);
+ assert.equal(testCaseWithMultipleCategories.has("MockCategory"), true);
+ })
+
+
+ });
+
+ function sumCases(result, predicate) {
+ return flattenTestCases(result).reduce( (total, testcase) => { return total + predicate(testcase)}, 0);
+ }
+
+ function findTestCaseById(result, id) {
+ return findTestCases(result, testcase => { return testcase.id == id})[0];
+ }
+
+ function findTestCases(result, predicate) {
+ return flattenTestCases(result).filter( testCase => {return predicate() == true});
+ }
+
+ function flattenTestCases(result) {
+ let items = [];
+ result.suites.forEach( s => {
+ s.cases.forEach( c => {
+ items.push(c);
+ });
+ });
+ return items;
+ }
+
+});
\ No newline at end of file