From 693652333bd6f4395ee24b35eda54f13fa5404b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:15:27 +0000 Subject: [PATCH 1/6] Initial plan From 808b7da346fa065b0517665d010acefa8e7688ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:20:32 +0000 Subject: [PATCH 2/6] Add JUnitSerializer with comprehensive tests Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../IO/JUnitSerializer.cs | 142 ++++++ .../IO/JUnitSerializerTests.cs | 438 ++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 src/DemaConsulting.TestResults/IO/JUnitSerializer.cs create mode 100644 test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs diff --git a/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs b/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs new file mode 100644 index 0000000..4b7b483 --- /dev/null +++ b/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs @@ -0,0 +1,142 @@ +// Copyright(c) 2025 DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace DemaConsulting.TestResults.IO; + +/// +/// JUnit Serializer class +/// +public static class JUnitSerializer +{ + /// + /// Serializes the TestResults object to a JUnit XML file + /// + /// Test Results + /// JUnit XML file contents + public static string Serialize(TestResults results) + { + // Group test results by class name for test suites + var testSuites = results.Results + .GroupBy(r => r.ClassName) + .OrderBy(g => g.Key); + + // Construct the root element + var root = new XElement("testsuites", + new XAttribute("name", results.Name)); + + // Add test suites for each class + foreach (var suiteGroup in testSuites) + { + var className = string.IsNullOrEmpty(suiteGroup.Key) ? "DefaultSuite" : suiteGroup.Key; + var suiteTests = suiteGroup.ToList(); + + var testSuite = new XElement("testsuite", + new XAttribute("name", className), + new XAttribute("tests", suiteTests.Count), + new XAttribute("failures", suiteTests.Count(t => t.Outcome == TestOutcome.Failed)), + new XAttribute("errors", suiteTests.Count(t => t.Outcome == TestOutcome.Error || t.Outcome == TestOutcome.Timeout || t.Outcome == TestOutcome.Aborted)), + new XAttribute("skipped", suiteTests.Count(t => !t.Outcome.IsExecuted())), + new XAttribute("time", suiteTests.Sum(t => t.Duration.TotalSeconds).ToString("F3", CultureInfo.InvariantCulture))); + + // Add test cases + foreach (var test in suiteTests) + { + var testCase = new XElement("testcase", + new XAttribute("name", test.Name), + new XAttribute("classname", string.IsNullOrEmpty(test.ClassName) ? "DefaultSuite" : test.ClassName), + new XAttribute("time", test.Duration.TotalSeconds.ToString("F3", CultureInfo.InvariantCulture))); + + // Add failure or error information + if (test.Outcome == TestOutcome.Failed) + { + var failure = new XElement("failure"); + if (!string.IsNullOrEmpty(test.ErrorMessage)) + { + failure.Add(new XAttribute("message", test.ErrorMessage)); + } + if (!string.IsNullOrEmpty(test.ErrorStackTrace)) + { + failure.Add(new XCData(test.ErrorStackTrace)); + } + testCase.Add(failure); + } + else if (test.Outcome == TestOutcome.Error || test.Outcome == TestOutcome.Timeout || test.Outcome == TestOutcome.Aborted) + { + var error = new XElement("error"); + if (!string.IsNullOrEmpty(test.ErrorMessage)) + { + error.Add(new XAttribute("message", test.ErrorMessage)); + } + if (!string.IsNullOrEmpty(test.ErrorStackTrace)) + { + error.Add(new XCData(test.ErrorStackTrace)); + } + testCase.Add(error); + } + else if (!test.Outcome.IsExecuted()) + { + var skipped = new XElement("skipped"); + if (!string.IsNullOrEmpty(test.ErrorMessage)) + { + skipped.Add(new XAttribute("message", test.ErrorMessage)); + } + testCase.Add(skipped); + } + + // Add system output + if (!string.IsNullOrEmpty(test.SystemOutput)) + { + testCase.Add(new XElement("system-out", new XCData(test.SystemOutput))); + } + + // Add system error + if (!string.IsNullOrEmpty(test.SystemError)) + { + testCase.Add(new XElement("system-err", new XCData(test.SystemError))); + } + + testSuite.Add(testCase); + } + + root.Add(testSuite); + } + + // Write the XML text + var doc = new XDocument(root); + var writer = new Utf8StringWriter(); + doc.Save(writer); + return writer.ToString(); + } + + /// + /// String writer that uses UTF-8 encoding + /// + private sealed class Utf8StringWriter : StringWriter + { + /// + /// Gets the UTF-8 encoding + /// + public override Encoding Encoding => Encoding.UTF8; + } +} diff --git a/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs b/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs new file mode 100644 index 0000000..3423f49 --- /dev/null +++ b/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs @@ -0,0 +1,438 @@ +// Copyright(c) 2025 DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Xml.Linq; +using DemaConsulting.TestResults.IO; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DemaConsulting.TestResults.Tests.IO; + +/// +/// Tests for JUnitSerializer class +/// +[TestClass] +public sealed class JUnitSerializerTests +{ + /// + /// Test for basic serialization with passed test + /// + [TestMethod] + public void TestSerializeBasic() + { + // Construct a basic test results object + var results = new TestResults + { + Name = "BasicTests", + Results = + [ + new TestResult + { + Name = "Test1", + ClassName = "MyTestClass", + Duration = TimeSpan.FromSeconds(1.5), + Outcome = TestOutcome.Passed + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var root = doc.Root; + Assert.IsNotNull(root); + Assert.AreEqual("testsuites", root.Name.LocalName); + Assert.AreEqual("BasicTests", root.Attribute("name")?.Value); + + // Verify test suite + var testSuite = root.Element("testsuite"); + Assert.IsNotNull(testSuite); + Assert.AreEqual("MyTestClass", testSuite.Attribute("name")?.Value); + Assert.AreEqual("1", testSuite.Attribute("tests")?.Value); + Assert.AreEqual("0", testSuite.Attribute("failures")?.Value); + Assert.AreEqual("0", testSuite.Attribute("errors")?.Value); + Assert.AreEqual("0", testSuite.Attribute("skipped")?.Value); + Assert.AreEqual("1.500", testSuite.Attribute("time")?.Value); + + // Verify test case + var testCase = testSuite.Element("testcase"); + Assert.IsNotNull(testCase); + Assert.AreEqual("Test1", testCase.Attribute("name")?.Value); + Assert.AreEqual("MyTestClass", testCase.Attribute("classname")?.Value); + Assert.AreEqual("1.500", testCase.Attribute("time")?.Value); + + // Verify no failure/error/skipped elements for passed test + Assert.IsNull(testCase.Element("failure")); + Assert.IsNull(testCase.Element("error")); + Assert.IsNull(testCase.Element("skipped")); + } + + /// + /// Test for serialization with failed test + /// + [TestMethod] + public void TestSerializeWithFailure() + { + // Construct test results with a failed test + var results = new TestResults + { + Name = "FailureTests", + Results = + [ + new TestResult + { + Name = "Test2", + ClassName = "MyTestClass", + Duration = TimeSpan.FromSeconds(0.5), + Outcome = TestOutcome.Failed, + ErrorMessage = "Expected value to be 42 but was 0", + ErrorStackTrace = "at MyTestClass.Test2() in Test.cs:line 15" + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var testSuite = doc.Root?.Element("testsuite"); + Assert.IsNotNull(testSuite); + Assert.AreEqual("1", testSuite.Attribute("tests")?.Value); + Assert.AreEqual("1", testSuite.Attribute("failures")?.Value); + Assert.AreEqual("0", testSuite.Attribute("errors")?.Value); + + // Verify test case with failure + var testCase = testSuite.Element("testcase"); + Assert.IsNotNull(testCase); + Assert.AreEqual("Test2", testCase.Attribute("name")?.Value); + + // Verify failure element + var failure = testCase.Element("failure"); + Assert.IsNotNull(failure); + Assert.AreEqual("Expected value to be 42 but was 0", failure.Attribute("message")?.Value); + Assert.Contains("at MyTestClass.Test2() in Test.cs:line 15", failure.Value); + } + + /// + /// Test for serialization with error test + /// + [TestMethod] + public void TestSerializeWithError() + { + // Construct test results with an error test + var results = new TestResults + { + Name = "ErrorTests", + Results = + [ + new TestResult + { + Name = "Test3", + ClassName = "MyTestClass", + Duration = TimeSpan.FromSeconds(0.1), + Outcome = TestOutcome.Error, + ErrorMessage = "Unexpected exception occurred", + ErrorStackTrace = "at MyTestClass.Test3() in Test.cs:line 20" + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var testSuite = doc.Root?.Element("testsuite"); + Assert.IsNotNull(testSuite); + Assert.AreEqual("1", testSuite.Attribute("tests")?.Value); + Assert.AreEqual("0", testSuite.Attribute("failures")?.Value); + Assert.AreEqual("1", testSuite.Attribute("errors")?.Value); + + // Verify test case with error + var testCase = testSuite.Element("testcase"); + Assert.IsNotNull(testCase); + var error = testCase.Element("error"); + Assert.IsNotNull(error); + Assert.AreEqual("Unexpected exception occurred", error.Attribute("message")?.Value); + Assert.Contains("at MyTestClass.Test3() in Test.cs:line 20", error.Value); + } + + /// + /// Test for serialization with skipped test + /// + [TestMethod] + public void TestSerializeWithSkipped() + { + // Construct test results with a skipped test + var results = new TestResults + { + Name = "SkippedTests", + Results = + [ + new TestResult + { + Name = "Test4", + ClassName = "MyTestClass", + Duration = TimeSpan.Zero, + Outcome = TestOutcome.NotExecuted, + ErrorMessage = "Test was skipped" + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var testSuite = doc.Root?.Element("testsuite"); + Assert.IsNotNull(testSuite); + Assert.AreEqual("1", testSuite.Attribute("tests")?.Value); + Assert.AreEqual("0", testSuite.Attribute("failures")?.Value); + Assert.AreEqual("0", testSuite.Attribute("errors")?.Value); + Assert.AreEqual("1", testSuite.Attribute("skipped")?.Value); + + // Verify test case with skipped element + var testCase = testSuite.Element("testcase"); + Assert.IsNotNull(testCase); + var skipped = testCase.Element("skipped"); + Assert.IsNotNull(skipped); + Assert.AreEqual("Test was skipped", skipped.Attribute("message")?.Value); + } + + /// + /// Test for serialization with system output + /// + [TestMethod] + public void TestSerializeWithSystemOutput() + { + // Construct test results with system output + var results = new TestResults + { + Name = "OutputTests", + Results = + [ + new TestResult + { + Name = "Test5", + ClassName = "MyTestClass", + Duration = TimeSpan.FromSeconds(1.0), + Outcome = TestOutcome.Passed, + SystemOutput = "Standard output message", + SystemError = "Standard error message" + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var testCase = doc.Root?.Element("testsuite")?.Element("testcase"); + Assert.IsNotNull(testCase); + + // Verify system-out element + var systemOut = testCase.Element("system-out"); + Assert.IsNotNull(systemOut); + Assert.AreEqual("Standard output message", systemOut.Value); + + // Verify system-err element + var systemErr = testCase.Element("system-err"); + Assert.IsNotNull(systemErr); + Assert.AreEqual("Standard error message", systemErr.Value); + } + + /// + /// Test for serialization with multiple test results + /// + [TestMethod] + public void TestSerializeMultipleTests() + { + // Construct test results with multiple tests + var results = new TestResults + { + Name = "MultipleTests", + Results = + [ + new TestResult + { + Name = "Test1", + ClassName = "Class1", + Duration = TimeSpan.FromSeconds(1.0), + Outcome = TestOutcome.Passed + }, + new TestResult + { + Name = "Test2", + ClassName = "Class1", + Duration = TimeSpan.FromSeconds(0.5), + Outcome = TestOutcome.Failed, + ErrorMessage = "Test failed" + }, + new TestResult + { + Name = "Test3", + ClassName = "Class2", + Duration = TimeSpan.FromSeconds(2.0), + Outcome = TestOutcome.Passed + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var root = doc.Root; + Assert.IsNotNull(root); + + // Verify two test suites (one for each class) + var testSuites = root.Elements("testsuite").ToList(); + Assert.HasCount(2, testSuites); + + // Verify first test suite (Class1) + var suite1 = testSuites[0]; + Assert.AreEqual("Class1", suite1.Attribute("name")?.Value); + Assert.AreEqual("2", suite1.Attribute("tests")?.Value); + Assert.AreEqual("1", suite1.Attribute("failures")?.Value); + Assert.AreEqual("1.500", suite1.Attribute("time")?.Value); + + // Verify second test suite (Class2) + var suite2 = testSuites[1]; + Assert.AreEqual("Class2", suite2.Attribute("name")?.Value); + Assert.AreEqual("1", suite2.Attribute("tests")?.Value); + Assert.AreEqual("0", suite2.Attribute("failures")?.Value); + Assert.AreEqual("2.000", suite2.Attribute("time")?.Value); + } + + /// + /// Test for serialization with empty class name + /// + [TestMethod] + public void TestSerializeEmptyClassName() + { + // Construct test results with empty class name + var results = new TestResults + { + Name = "EmptyClassTests", + Results = + [ + new TestResult + { + Name = "Test1", + ClassName = string.Empty, + Duration = TimeSpan.FromSeconds(1.0), + Outcome = TestOutcome.Passed + } + ] + }; + + // Serialize the test results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the XML structure + var doc = XDocument.Parse(xml); + var testSuite = doc.Root?.Element("testsuite"); + Assert.IsNotNull(testSuite); + Assert.AreEqual("DefaultSuite", testSuite.Attribute("name")?.Value); + + var testCase = testSuite.Element("testcase"); + Assert.IsNotNull(testCase); + Assert.AreEqual("DefaultSuite", testCase.Attribute("classname")?.Value); + } + + /// + /// Test for serialization matching the usage example from the issue + /// + [TestMethod] + public void TestSerializeUsageExample() + { + // Create a TestResults instance matching the usage example + var results = new TestResults { Name = "SomeTests" }; + + // Add some results + results.Results.Add( + new TestResult + { + Name = "Test1", + ClassName = "SomeTestClass", + CodeBase = "MyTestAssembly", + Outcome = TestOutcome.Passed, + Duration = TimeSpan.FromSeconds(1.5), + StartTime = DateTime.UtcNow, + }); + + results.Results.Add( + new TestResult + { + Name = "Test2", + ClassName = "SomeTestClass", + CodeBase = "MyTestAssembly", + Outcome = TestOutcome.Failed, + ErrorMessage = "Expected value to be 42 but was 0", + ErrorStackTrace = "at SomeTestClass.Test2() in Test.cs:line 15" + }); + + // Serialize the results + var xml = JUnitSerializer.Serialize(results); + Assert.IsNotNull(xml); + + // Parse and verify the structure matches expected JUnit format + var doc = XDocument.Parse(xml); + var root = doc.Root; + Assert.IsNotNull(root); + Assert.AreEqual("testsuites", root.Name.LocalName); + Assert.AreEqual("SomeTests", root.Attribute("name")?.Value); + + // Verify test suite + var testSuite = root.Element("testsuite"); + Assert.IsNotNull(testSuite); + Assert.AreEqual("SomeTestClass", testSuite.Attribute("name")?.Value); + Assert.AreEqual("2", testSuite.Attribute("tests")?.Value); + Assert.AreEqual("1", testSuite.Attribute("failures")?.Value); + + // Verify both test cases are present + var testCases = testSuite.Elements("testcase").ToList(); + Assert.HasCount(2, testCases); + + // Verify passed test + var passedTest = testCases.FirstOrDefault(tc => tc.Attribute("name")?.Value == "Test1"); + Assert.IsNotNull(passedTest); + Assert.IsNull(passedTest.Element("failure")); + + // Verify failed test + var failedTest = testCases.FirstOrDefault(tc => tc.Attribute("name")?.Value == "Test2"); + Assert.IsNotNull(failedTest); + var failure = failedTest.Element("failure"); + Assert.IsNotNull(failure); + Assert.AreEqual("Expected value to be 42 but was 0", failure.Attribute("message")?.Value); + } +} From 4f236054383f8e5ddef37903b55a5581f47f6ee3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:40:08 +0000 Subject: [PATCH 3/6] Update documentation to reflect both TRX and JUnit format support Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- AGENTS.md | 2 +- ARCHITECTURE.md | 27 +++++++++++++++++--------- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++----- SECURITY.md | 4 ++-- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 193499c..a07e829 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ TestResults is a C# library for creating test result files. The library is desig - **Type-Safe**: Strongly-typed C# objects - **Cross-Platform**: Supports .NET 8, 9, and 10 -The library currently focuses on TRX format, with potential for additional formats in the future. +The library supports both TRX and JUnit XML formats, with potential for additional formats in the future. ## Technologies and Dependencies diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 692f234..ec929dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3,9 +3,9 @@ ## Overview The TestResults library is a lightweight C# library designed to programmatically create test result files. -The library currently focuses on the TRX (Test Results) format, with potential for additional formats in the future. +The library supports both TRX (Test Results) and JUnit XML formats. TRX files are XML-based test result files commonly used by Visual Studio, Azure DevOps, and other Microsoft testing -tools to store and visualize test execution results. +tools, while JUnit XML is widely used across various CI/CD systems and testing frameworks. ## Design Philosophy @@ -67,13 +67,22 @@ The `TrxSerializer` class is responsible for converting the domain model into TR - Produces TRX files compatible with Visual Studio and Azure DevOps - Handles proper formatting and schema compliance +#### `JUnitSerializer` + +The `JUnitSerializer` class is responsible for converting the domain model into JUnit XML format: + +- Uses .NET's built-in XML serialization capabilities +- Produces JUnit XML files compatible with various CI/CD systems and testing tools +- Groups test results by class name into test suites +- Maps test outcomes to JUnit semantics (failure, error, skipped) + ## Data Flow ```text 1. User creates TestResults instance 2. User adds TestResult objects to the Results collection -3. User calls TrxSerializer.Serialize() to convert to TRX XML format -4. User saves the XML string to a .trx file +3. User calls TrxSerializer.Serialize() or JUnitSerializer.Serialize() to convert to desired format +4. User saves the XML string to a .trx or .xml file ``` ## Design Patterns @@ -95,8 +104,8 @@ The library source code is organized in the `/src/DemaConsulting.TestResults/` d The library is designed to be extended in several ways: 1. **Custom Test Outcomes**: While the standard outcomes cover most scenarios, custom outcomes could be added -2. **Additional Metadata**: The model could be extended to support additional TRX metadata fields -3. **Alternative Serializers**: Additional serializers could be added for other test result formats +2. **Additional Metadata**: The model could be extended to support additional metadata fields +3. **Alternative Serializers**: Additional serializers could be added for other test result formats (NUnit, xUnit, etc.) ## Quality Attributes @@ -128,10 +137,10 @@ The library is designed to be extended in several ways: Potential enhancements that could be considered: -1. **Deserialization**: Add support for reading existing TRX files back into the object model -2. **Additional Formats**: Support for other test result formats (JUnit XML, NUnit XML, etc.) +1. **Deserialization**: Add support for reading existing TRX and JUnit XML files back into the object model +2. **Additional Formats**: Support for other test result formats (NUnit XML, xUnit XML, etc.) 3. **Streaming**: Support for streaming large test result sets to avoid memory issues -4. **Validation**: Add schema validation to ensure generated TRX files are well-formed +4. **Validation**: Add schema validation to ensure generated files are well-formed ## Dependencies diff --git a/README.md b/README.md index 4ee7cc1..bd5b33f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=demaconsulting_TestResults&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=demaconsulting_TestResults) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=demaconsulting_TestResults&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=demaconsulting_TestResults) -A lightweight C# library for programmatically creating TRX (Test Results) files. +A lightweight C# library for programmatically creating test result files in TRX and JUnit formats. ## Features @@ -18,6 +18,7 @@ A lightweight C# library for programmatically creating TRX (Test Results) files. - 🔄 **Multi-Target** - Supports .NET 8, 9, and 10 - 📦 **NuGet Ready** - Easy integration via NuGet package - ✅ **Compatible** - Generates TRX files compatible with Visual Studio, Azure DevOps, and other Microsoft testing tools +- 📊 **Multiple Formats** - Supports both TRX and JUnit XML formats ## Installation @@ -35,6 +36,8 @@ Install-Package DemaConsulting.TestResults ## Quick Start +### Creating TRX Files + The following code-snippet shows how to create a TRX test-results file: ```csharp @@ -68,12 +71,49 @@ results.Results.Add( ErrorStackTrace = "at SomeTestClass.Test2() in Test.cs:line 15" }); -// Save the results to file +// Save the results to TRX file File.WriteAllText( "results.trx", TrxSerializer.Serialize(results)); ``` +### Creating JUnit XML Files + +The following code-snippet shows how to create a JUnit XML test-results file: + +```csharp +using DemaConsulting.TestResults; +using DemaConsulting.TestResults.IO; + +// Create a TestResults instance +var results = new TestResults { Name = "SomeTests" }; + +// Add some results +results.Results.Add( + new TestResult + { + Name = "Test1", + ClassName = "SomeTestClass", + Outcome = TestOutcome.Passed, + Duration = TimeSpan.FromSeconds(1.5) + }); + +results.Results.Add( + new TestResult + { + Name = "Test2", + ClassName = "SomeTestClass", + Outcome = TestOutcome.Failed, + ErrorMessage = "Expected value to be 42 but was 0", + ErrorStackTrace = "at SomeTestClass.Test2() in Test.cs:line 15" + }); + +// Save the results to JUnit XML file +File.WriteAllText( + "results.xml", + JUnitSerializer.Serialize(results)); +``` + ## Advanced Usage ### Capturing Standard Output @@ -120,11 +160,11 @@ The library supports the following test outcomes: This library is useful when you need to: -- Generate TRX files from custom test runners -- Convert test results from other formats to TRX +- Generate TRX or JUnit XML files from custom test runners +- Convert test results between formats (TRX ↔ JUnit) - Create test reports programmatically - Aggregate test results from multiple sources -- Build custom testing tools that integrate with Visual Studio or Azure DevOps +- Build custom testing tools that integrate with Visual Studio, Azure DevOps, or CI/CD systems that consume JUnit XML ## Documentation diff --git a/SECURITY.md b/SECURITY.md index 7b772e8..f1605ad 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -103,8 +103,8 @@ Historical security advisories can be found in the When using the TestResults library: 1. **Keep Updated**: Always use the latest version of the library -2. **Validate Input**: If accepting TRX file paths from users, validate and sanitize them -3. **File Permissions**: When writing TRX files, use appropriate file permissions +2. **Validate Input**: If accepting test result file paths from users, validate and sanitize them +3. **File Permissions**: When writing test result files (TRX or JUnit XML), use appropriate file permissions 4. **Error Handling**: Implement proper error handling when using the library 5. **Code Review**: Review any code that uses this library as part of your security practices From 4e94fc4b0075d49159e4e9a3e4c26c9a96b07a47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:50:18 +0000 Subject: [PATCH 4/6] Add JUnitSerializer.Deserialize method with comprehensive tests Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../IO/JUnitSerializer.cs | 96 ++++++ .../IO/JUnitSerializerTests.cs | 273 ++++++++++++++++++ 2 files changed, 369 insertions(+) diff --git a/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs b/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs index 4b7b483..2ec97b4 100644 --- a/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs +++ b/src/DemaConsulting.TestResults/IO/JUnitSerializer.cs @@ -129,6 +129,102 @@ public static string Serialize(TestResults results) return writer.ToString(); } + /// + /// Deserializes a JUnit XML file to a TestResults object + /// + /// JUnit XML File Contents + /// Test Results + public static TestResults Deserialize(string junitContents) + { + // Parse the document + var doc = XDocument.Parse(junitContents); + + // Construct the results + var results = new TestResults(); + + // Get the root element (testsuites) + var rootElement = doc.Root ?? + throw new InvalidOperationException("Invalid JUnit XML file"); + + // Get the test suite name (from testsuites or first testsuite) + results.Name = rootElement.Attribute("name")?.Value ?? string.Empty; + + // Handle both testsuites (with nested testsuite) and single testsuite root + var testSuiteElements = rootElement.Name.LocalName == "testsuites" + ? rootElement.Elements("testsuite") + : new[] { rootElement }; + + // Process each test suite + foreach (var testSuiteElement in testSuiteElements) + { + // Get test cases + var testCaseElements = testSuiteElement.Elements("testcase"); + + foreach (var testCaseElement in testCaseElements) + { + // Parse test case attributes + var name = testCaseElement.Attribute("name")?.Value ?? string.Empty; + var className = testCaseElement.Attribute("classname")?.Value ?? string.Empty; + var timeStr = testCaseElement.Attribute("time")?.Value ?? "0"; + var duration = double.TryParse(timeStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var timeValue) + ? TimeSpan.FromSeconds(timeValue) + : TimeSpan.Zero; + + // Determine outcome based on child elements + var failureElement = testCaseElement.Element("failure"); + var errorElement = testCaseElement.Element("error"); + var skippedElement = testCaseElement.Element("skipped"); + + TestOutcome outcome; + string errorMessage = string.Empty; + string errorStackTrace = string.Empty; + + if (failureElement != null) + { + outcome = TestOutcome.Failed; + errorMessage = failureElement.Attribute("message")?.Value ?? string.Empty; + errorStackTrace = failureElement.Value; + } + else if (errorElement != null) + { + outcome = TestOutcome.Error; + errorMessage = errorElement.Attribute("message")?.Value ?? string.Empty; + errorStackTrace = errorElement.Value; + } + else if (skippedElement != null) + { + outcome = TestOutcome.NotExecuted; + errorMessage = skippedElement.Attribute("message")?.Value ?? string.Empty; + } + else + { + outcome = TestOutcome.Passed; + } + + // Get system output and error + var systemOutput = testCaseElement.Element("system-out")?.Value ?? string.Empty; + var systemError = testCaseElement.Element("system-err")?.Value ?? string.Empty; + + // Create test result + var testResult = new TestResult + { + Name = name, + ClassName = className == "DefaultSuite" ? string.Empty : className, + Duration = duration, + Outcome = outcome, + ErrorMessage = errorMessage, + ErrorStackTrace = errorStackTrace, + SystemOutput = systemOutput, + SystemError = systemError + }; + + results.Results.Add(testResult); + } + } + + return results; + } + /// /// String writer that uses UTF-8 encoding /// diff --git a/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs b/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs index 3423f49..0914045 100644 --- a/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs +++ b/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs @@ -435,4 +435,277 @@ public void TestSerializeUsageExample() Assert.IsNotNull(failure); Assert.AreEqual("Expected value to be 42 but was 0", failure.Attribute("message")?.Value); } + + /// + /// Test for basic deserialization + /// + [TestMethod] + public void TestDeserializeBasic() + { + // Deserialize the test results object + var results = JUnitSerializer.Deserialize( + """ + + + + + + + """); + Assert.IsNotNull(results); + + // Assert results information + Assert.AreEqual("BasicTests", results.Name); + Assert.HasCount(1, results.Results); + + // Assert test result information + var result = results.Results[0]; + Assert.AreEqual("Test1", result.Name); + Assert.AreEqual("MyTestClass", result.ClassName); + Assert.AreEqual(1.5, result.Duration.TotalSeconds); + Assert.AreEqual(TestOutcome.Passed, result.Outcome); + } + + /// + /// Test for deserialization with failure + /// + [TestMethod] + public void TestDeserializeWithFailure() + { + // Deserialize the test results object with a failed test + var results = JUnitSerializer.Deserialize( + """ + + + + + + + + + """); + Assert.IsNotNull(results); + + // Assert results information + Assert.AreEqual("FailureTests", results.Name); + Assert.HasCount(1, results.Results); + + // Assert test result information + var result = results.Results[0]; + Assert.AreEqual("Test2", result.Name); + Assert.AreEqual("MyTestClass", result.ClassName); + Assert.AreEqual(TestOutcome.Failed, result.Outcome); + Assert.AreEqual("Expected value to be 42 but was 0", result.ErrorMessage); + Assert.Contains("at MyTestClass.Test2() in Test.cs:line 15", result.ErrorStackTrace); + } + + /// + /// Test for deserialization with error + /// + [TestMethod] + public void TestDeserializeWithError() + { + // Deserialize the test results object with an error test + var results = JUnitSerializer.Deserialize( + """ + + + + + + + + + """); + Assert.IsNotNull(results); + + // Assert test result information + var result = results.Results[0]; + Assert.AreEqual("Test3", result.Name); + Assert.AreEqual(TestOutcome.Error, result.Outcome); + Assert.AreEqual("Unexpected exception occurred", result.ErrorMessage); + Assert.Contains("at MyTestClass.Test3() in Test.cs:line 20", result.ErrorStackTrace); + } + + /// + /// Test for deserialization with skipped test + /// + [TestMethod] + public void TestDeserializeWithSkipped() + { + // Deserialize the test results object with a skipped test + var results = JUnitSerializer.Deserialize( + """ + + + + + + + + + """); + Assert.IsNotNull(results); + + // Assert test result information + var result = results.Results[0]; + Assert.AreEqual("Test4", result.Name); + Assert.AreEqual(TestOutcome.NotExecuted, result.Outcome); + Assert.AreEqual("Test was skipped", result.ErrorMessage); + } + + /// + /// Test for deserialization with system output + /// + [TestMethod] + public void TestDeserializeWithSystemOutput() + { + // Deserialize the test results object with system output + var results = JUnitSerializer.Deserialize( + """ + + + + + + + + + + """); + Assert.IsNotNull(results); + + // Assert test result information + var result = results.Results[0]; + Assert.AreEqual("Test5", result.Name); + Assert.AreEqual("Standard output message", result.SystemOutput); + Assert.AreEqual("Standard error message", result.SystemError); + } + + /// + /// Test for deserialization with multiple test suites + /// + [TestMethod] + public void TestDeserializeMultipleTestSuites() + { + // Deserialize the test results object with multiple test suites + var results = JUnitSerializer.Deserialize( + """ + + + + + + + + + + + + + """); + Assert.IsNotNull(results); + + // Assert results information + Assert.AreEqual("MultipleTests", results.Name); + Assert.HasCount(3, results.Results); + + // Verify first test + var test1 = results.Results[0]; + Assert.AreEqual("Test1", test1.Name); + Assert.AreEqual("Class1", test1.ClassName); + Assert.AreEqual(TestOutcome.Passed, test1.Outcome); + + // Verify second test + var test2 = results.Results[1]; + Assert.AreEqual("Test2", test2.Name); + Assert.AreEqual(TestOutcome.Failed, test2.Outcome); + + // Verify third test + var test3 = results.Results[2]; + Assert.AreEqual("Test3", test3.Name); + Assert.AreEqual("Class2", test3.ClassName); + Assert.AreEqual(TestOutcome.Passed, test3.Outcome); + } + + /// + /// Test for deserialization with empty class name (DefaultSuite) + /// + [TestMethod] + public void TestDeserializeEmptyClassName() + { + // Deserialize the test results object with DefaultSuite + var results = JUnitSerializer.Deserialize( + """ + + + + + + + """); + Assert.IsNotNull(results); + + // Assert test result information - DefaultSuite should be converted to empty string + var result = results.Results[0]; + Assert.AreEqual("Test1", result.Name); + Assert.AreEqual(string.Empty, result.ClassName); + } + + /// + /// Test for round-trip serialization and deserialization + /// + [TestMethod] + public void TestRoundTrip() + { + // Create original test results + var original = new TestResults { Name = "RoundTripTests" }; + original.Results.Add( + new TestResult + { + Name = "Test1", + ClassName = "TestClass", + Duration = TimeSpan.FromSeconds(1.5), + Outcome = TestOutcome.Passed, + SystemOutput = "Output message" + }); + original.Results.Add( + new TestResult + { + Name = "Test2", + ClassName = "TestClass", + Duration = TimeSpan.FromSeconds(0.5), + Outcome = TestOutcome.Failed, + ErrorMessage = "Test failed", + ErrorStackTrace = "Stack trace here" + }); + + // Serialize to JUnit XML + var xml = JUnitSerializer.Serialize(original); + + // Deserialize back + var deserialized = JUnitSerializer.Deserialize(xml); + + // Verify results match + Assert.AreEqual(original.Name, deserialized.Name); + Assert.HasCount(original.Results.Count, deserialized.Results); + + // Verify first test + var origTest1 = original.Results[0]; + var deserTest1 = deserialized.Results[0]; + Assert.AreEqual(origTest1.Name, deserTest1.Name); + Assert.AreEqual(origTest1.ClassName, deserTest1.ClassName); + Assert.AreEqual(origTest1.Duration.TotalSeconds, deserTest1.Duration.TotalSeconds, 0.001); + Assert.AreEqual(origTest1.Outcome, deserTest1.Outcome); + Assert.AreEqual(origTest1.SystemOutput, deserTest1.SystemOutput); + + // Verify second test + var origTest2 = original.Results[1]; + var deserTest2 = deserialized.Results[1]; + Assert.AreEqual(origTest2.Name, deserTest2.Name); + Assert.AreEqual(origTest2.ClassName, deserTest2.ClassName); + Assert.AreEqual(origTest2.Outcome, deserTest2.Outcome); + Assert.AreEqual(origTest2.ErrorMessage, deserTest2.ErrorMessage); + Assert.AreEqual(origTest2.ErrorStackTrace, deserTest2.ErrorStackTrace); + } } From 5817a623e7d9007cd91a239968f1d6369bf23a3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:54:16 +0000 Subject: [PATCH 5/6] Update documentation with format conversion examples Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- ARCHITECTURE.md | 3 ++- README.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec929dd..104eccc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,10 +69,11 @@ The `TrxSerializer` class is responsible for converting the domain model into TR #### `JUnitSerializer` -The `JUnitSerializer` class is responsible for converting the domain model into JUnit XML format: +The `JUnitSerializer` class is responsible for converting between the domain model and JUnit XML format: - Uses .NET's built-in XML serialization capabilities - Produces JUnit XML files compatible with various CI/CD systems and testing tools +- Can deserialize JUnit XML files back into the domain model - Groups test results by class name into test suites - Maps test outcomes to JUnit semantics (failure, error, skipped) diff --git a/README.md b/README.md index bd5b33f..cd75071 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,28 @@ File.WriteAllText( JUnitSerializer.Serialize(results)); ``` +### Converting Between Formats + +The library supports reading and converting between TRX and JUnit formats: + +```csharp +using DemaConsulting.TestResults.IO; + +// Read JUnit XML file +var junitXml = File.ReadAllText("junit-results.xml"); +var results = JUnitSerializer.Deserialize(junitXml); + +// Convert to TRX format +var trxXml = TrxSerializer.Serialize(results); +File.WriteAllText("converted.trx", trxXml); + +// Or convert TRX to JUnit +var trxXml2 = File.ReadAllText("test-results.trx"); +var results2 = TrxSerializer.Deserialize(trxXml2); +var junitXml2 = JUnitSerializer.Serialize(results2); +File.WriteAllText("converted.xml", junitXml2); +``` + ## Advanced Usage ### Capturing Standard Output From d18dd1d74a6545f09f47b9147d280247301461bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:35:17 +0000 Subject: [PATCH 6/6] Fix variable naming: expand "deser" to "deserialized" Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../IO/JUnitSerializerTests.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs b/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs index 0914045..15a737f 100644 --- a/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs +++ b/test/DemaConsulting.TestResults.Tests/IO/JUnitSerializerTests.cs @@ -692,20 +692,20 @@ public void TestRoundTrip() // Verify first test var origTest1 = original.Results[0]; - var deserTest1 = deserialized.Results[0]; - Assert.AreEqual(origTest1.Name, deserTest1.Name); - Assert.AreEqual(origTest1.ClassName, deserTest1.ClassName); - Assert.AreEqual(origTest1.Duration.TotalSeconds, deserTest1.Duration.TotalSeconds, 0.001); - Assert.AreEqual(origTest1.Outcome, deserTest1.Outcome); - Assert.AreEqual(origTest1.SystemOutput, deserTest1.SystemOutput); + var deserializedTest1 = deserialized.Results[0]; + Assert.AreEqual(origTest1.Name, deserializedTest1.Name); + Assert.AreEqual(origTest1.ClassName, deserializedTest1.ClassName); + Assert.AreEqual(origTest1.Duration.TotalSeconds, deserializedTest1.Duration.TotalSeconds, 0.001); + Assert.AreEqual(origTest1.Outcome, deserializedTest1.Outcome); + Assert.AreEqual(origTest1.SystemOutput, deserializedTest1.SystemOutput); // Verify second test var origTest2 = original.Results[1]; - var deserTest2 = deserialized.Results[1]; - Assert.AreEqual(origTest2.Name, deserTest2.Name); - Assert.AreEqual(origTest2.ClassName, deserTest2.ClassName); - Assert.AreEqual(origTest2.Outcome, deserTest2.Outcome); - Assert.AreEqual(origTest2.ErrorMessage, deserTest2.ErrorMessage); - Assert.AreEqual(origTest2.ErrorStackTrace, deserTest2.ErrorStackTrace); + var deserializedTest2 = deserialized.Results[1]; + Assert.AreEqual(origTest2.Name, deserializedTest2.Name); + Assert.AreEqual(origTest2.ClassName, deserializedTest2.ClassName); + Assert.AreEqual(origTest2.Outcome, deserializedTest2.Outcome); + Assert.AreEqual(origTest2.ErrorMessage, deserializedTest2.ErrorMessage); + Assert.AreEqual(origTest2.ErrorStackTrace, deserializedTest2.ErrorStackTrace); } }