Repository: weiwei/junitparser

Issue:
merge JUnit XML with TestSuites vs TestSuite as root element should not result in nested.
**Description:**
I got the following nested results when merge one JUnit XML with root element `testsuite` and another JUnit XML with root element `testsuites`.

JUnit XML with `testsuite`(name `test_suite.xml` in code):
```
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="com.example.unittestdemo.ExampleUnitTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2020-11-19T14:34:55" hostname="MyHost" time="0.002">
  <properties/>
  <testcase name="division_isCorrect" classname="com.example.unittestdemo.ExampleUnitTest" time="0.002"/>
   <system-out><![CDATA[]]></system-out>
  <system-err><![CDATA[]]></system-err>
</testsuite>
```

JUnit XML with `testsuites`(name `test_suites.xml` in code):
```
<testsuites tests="1" skipped="0" failures="0" errors="0" timestamp="2020-12-03T07:55:23" hostname="MyHost"
            time="0.005">
    <testsuite name="com.example.base.BaseUnitTest" tests="1" skipped="0" failures="0" errors="0"
               timestamp="2020-12-03T07:55:23" hostname="MyHost" time="0.005">
        <properties/>
        <testcase name="division_isCorrect" classname="com.example.base.BaseUnitTest" time="0.005"/>
        </testcase>
        <system-out><![CDATA[]]></system-out>
        <system-err><![CDATA[]]></system-err>
    </testsuite>
</testsuites>
```

And I run the code:
```
xml_suite_path = "test_suite.xml"
xml_suites_path = "test_suites.xml"

xml_suite = JUnitXml.fromfile(filepath=xml_suite_path)
xml_suites = JUnitXml.fromfile(filepath=xml_suites_path)


merged_xml = xml_suite + xml_suites


merged_xml.write("merged.xml")
```

**Actual:**
```
<?xml version='1.0' encoding='utf-8'?>
<testsuites>
    <testsuite name="com.example.unittestdemo.ExampleUnitTest" tests="1" skipped="0" failures="0" errors="0"
               timestamp="2020-11-19T14:34:55" hostname="MyHost" time="0.002">
        <properties/>
        <testcase name="division_isCorrect" classname="com.example.unittestdemo.ExampleUnitTest" time="0.002"/>
        <system-out><![CDATA[]]></system-out>
        <system-err><![CDATA[]]></system-err>
    </testsuite>
    <testsuites tests="1" skipped="0" failures="0" errors="0" timestamp="2020-12-03T07:55:23" hostname="MyHost"
                time="0.005">
        <testsuite name="com.example.base.BaseUnitTest" tests="1" skipped="0" failures="0" errors="0"
                   timestamp="2020-12-03T07:55:23" hostname=MyHost" time="0.005">
            <properties/>
            <testcase name="division_isCorrect" classname="com.example.base.BaseUnitTest" time="0.005"/>
            <system-out><![CDATA[]]></system-out>
            <system-err><![CDATA[]]></system-err>
        </testsuite>
    </testsuites>
</testsuites>
```

**Expected:**
```
<testsuites tests="5" failures="1" errors="0" skipped="1" time="0.004">
    <testsuite name="com.example.unittestdemo.ExampleUnitTest" tests="1" skipped="0" failures="0" errors="0"
               timestamp="2020-11-19T14:34:55" hostname="MyHost" time="0.002">
        <properties/>
        <testcase name="division_isCorrect" classname="com.example.unittestdemo.ExampleUnitTest" time="0.002"/>
        <system-out><![CDATA[]]></system-out>
        <system-err><![CDATA[]]></system-err>
    </testsuite>
    
    <testsuite name="com.example.base.BaseUnitTest" tests="1" skipped="0" failures="0" errors="0"
               timestamp="2020-12-03T07:55:23" hostname=MyHost" time="0.005">
        <properties/>
        <testcase name="division_isCorrect" classname="com.example.base.BaseUnitTest" time="0.005"/>
        <system-out><![CDATA[]]></system-out>
        <system-err><![CDATA[]]></system-err>
    </testsuite>
</testsuites>
```

**My Solution:**
And currently I solved this problem by converting all `testsuite` JUnit XML to `testsuites` JUnit XML and then update the statistics.

```

def convert_to_test_suites_if_needed(xml):
    if isinstance(xml, TestSuite):
        new_xml = JUnitXml()
        new_xml.add_testsuite(xml)
        return new_xml

    return xml

xml_suite_path = "test_suite.xml"
xml_suites_path = "test_suites.xml"

xml_suite = convert_to_test_suites_if_needed(JUnitXml.fromfile(filepath=xml_suite_path))
xml_suites = convert_to_test_suites_if_neededJUnitXml.fromfile(filepath=xml_suites_path))


merged_xml = xml_suite + xml_suites

merged_xml.update_statistics()


merged_xml.write("merged.xml")
```

I think this could be done in junitparser side to make it compatibile with merging `testsuite` and `testsuites` and get the right statistics.

Hints from issue comments:
@dyollb: Here is some example data which shows the issue:

If you merge first the mypy xml, then the pytest xml, the result is wrong (nested testsuites).
If you switch the order (pytest with two testsuites, then mypy) the result is as expected.

The workaround proposed above behaves correctly for both cases.

```xml
<?xml version="1.0" encoding="utf-8"?>
<testsuite errors="0" failures="0" name="mypy" skips="0" tests="1" time="1.009">
  <testcase classname="mypy" file="mypy" line="1" name="mypy-py3_8-win32" time="1.009">
  </testcase>
</testsuite>
```

```xml
<?xml version="1.0" encoding="utf-8"?>
<testsuites>
    <testsuite errors="0" failures="0" hostname="GAIA02" name="pytest" skipped="0" tests="2" time="0.630" timestamp="2023-08-18T14:49:23.787800">
        <testcase classname="tests.test_crop_box" file="tests\test_crop_box.py" line="9" name="test_crop_box" time="0.094"></testcase>
        <testcase classname="tests.test_utils" file="tests\test_utils.py" line="5" name="test_collect_files" time="0.001"></testcase>
    </testsuite>
</testsuites>
```
@EnricoMi: See #142.

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 05901bd..1f51c0e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,8 +30,6 @@ jobs:
       # EOL Python versions
       include: >
         [
-          {"os": "ubuntu-20.04", "python-version": "3.6"},
-          {"os": "ubuntu-20.04", "python-version": "3.7"},
           {"os": "ubuntu-22.04", "python-version": "3.8"},
         ]
 
@@ -84,7 +82,6 @@ jobs:
       run: |
         python -m pip install --upgrade pip
         pip install lxml flake8 pytest coverage
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
     - name: Lint with flake8
       run: |
         # The GitHub editor is 127 chars wide
@@ -94,8 +91,8 @@ jobs:
         coverage run -m pytest
         bash <(curl -s https://codecov.io/bash)
 
-  package-publish:
-    name: "Publish Package"
+  package-build:
+    name: "Build Package"
     needs: coverage-lint
     runs-on: ubuntu-latest
     steps:
@@ -106,24 +103,36 @@ jobs:
         python-version: 3.x
     - name: Build packages
       run: |
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-        pip install setuptools wheel
-        python setup.py sdist bdist_wheel
+        # Build packages
+        echo "::group::build"
+        pip install build
+        python -m build
+        echo "::endgroup::"
+
+        echo "::group::dist/"
+        ls -lah dist/
+        echo "::endgroup::"
+
+        echo "::group::tar.gz"
+        tar -tzvf dist/junitparser-*.tar.gz
+        echo "::endgroup::"
+
+        echo "::group::wheel"
+        unzip -l dist/junitparser-*.whl
+        echo "::endgroup::"
+
+        echo "::group::wheel metadata"
+        unzip -p dist/junitparser-*.whl junitparser-*.dist-info/METADATA
+        echo "::endgroup::"
     - name: Upload
       uses: actions/upload-artifact@v4
       with:
         name: Binaries
         path: dist/*
-    - name: Publish to pypi
-      if: startsWith(github.ref, 'refs/tags')
-      uses: pypa/gh-action-pypi-publish@release/v1
-      with:
-        user: __token__
-        password: ${{ secrets.pypi_password }}
 
   test-whl:
     name: "Test whl"
-    needs: package-publish
+    needs: package-build
     uses: "./.github/workflows/test-whl.yml"
     with:
       os: '["ubuntu-24.04"]'
@@ -132,7 +141,26 @@ jobs:
       # EOL Python versions
       include: >
         [
-          {"os": "ubuntu-20.04", "python-version": "3.6"},
-          {"os": "ubuntu-20.04", "python-version": "3.7"},
           {"os": "ubuntu-22.04", "python-version": "3.8"},
         ]
+
+  package-publish:
+    name: "Publish Package"
+    needs: test-whl
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+    - name: Download Binaries
+      uses: actions/download-artifact@v4
+      with:
+        name: Binaries
+        path: dist/
+    - name: Inspect binaries
+      run: |
+        ls -lah dist/
+    - name: Publish to pypi
+      if: startsWith(github.ref, 'refs/tags')
+      uses: pypa/gh-action-pypi-publish@release/v1
+      with:
+        user: __token__
+        password: ${{ secrets.pypi_password }}
diff --git a/.github/workflows/test-os.yml b/.github/workflows/test-os.yml
index fb65cd5..edf1adb 100644
--- a/.github/workflows/test-os.yml
+++ b/.github/workflows/test-os.yml
@@ -40,7 +40,6 @@ jobs:
       run: |
         python -m pip install --upgrade pip
         pip install pytest
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
       shell: bash
     - name: Test
       run: |
diff --git a/.github/workflows/test-whl.yml b/.github/workflows/test-whl.yml
index 0c86383..d4b1ebb 100644
--- a/.github/workflows/test-whl.yml
+++ b/.github/workflows/test-whl.yml
@@ -37,7 +37,7 @@ jobs:
     - name: Run junitparser CLI
       run: |
         pip install dist/junitparser-*.whl
-        wget https://raw.githubusercontent.com/weiwei/junitparser/003587443fd6f332e9a6bd4b027e657a76cca033/tests/data/no_fails.xml
+        tar -xzf dist/junitparser-*.tar.gz --to-stdout --wildcards "junitparser-*/tests/data/no_fails.xml" > no_fails.xml
         junitparser verify no_fails.xml
         pip install lxml
         junitparser verify no_fails.xml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3ed0c6..ac63ed9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,13 @@
 - Setter method `TestCase.result` used to ignore values of invalid types. This method now throws a `ValueError` instead.
 - Method `xunit2.TestCase.add_rerun_result` has been renamed to `add_interim_result` result to better reflect class hierarchy
   of interim (rerun and flaky) results.
+- Methods `JUnitXml.fromfile`, `JUnitXml.fromstring`, `JUnitXml.fromroot` always return a `JUnitXml` instance.
+  Earlier versions return a `TestSuite` instance when the root of the file / string / element is a `<TestSuite>`.
+  A `JUnitXml` instance has already been returned by earlier versions when the root of the file / string / element is a `<TestSuites>`.
+
+  If you want to create a `TestSuite` instance from a `<TestSuite>` element, use
+
+    TestSuite.fromelem(elem)
 
 ## [3.1.2] - 2024-08-31
 ### Fixed
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..697c9ac
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1 @@
+include tests/data/*.xml
diff --git a/README.rst b/README.rst
index 0187d1e..a082467 100644
--- a/README.rst
+++ b/README.rst
@@ -1,7 +1,7 @@
 junitparser -- Pythonic JUnit/xUnit Result XML Parser
 ======================================================
 
-.. image:: https://github.com/weiwei/junitparser/workflows/build/badge.svg?branch=master
+.. image:: https://github.com/weiwei/junitparser/actions/workflows/ci.yml/badge.svg?branch=master
    :target: https://github.com/weiwei/junitparser/actions
 .. image:: https://codecov.io/gh/weiwei/junitparser/branch/master/graph/badge.svg?token=UotlfRXNnK
    :target: https://codecov.io/gh/weiwei/junitparser
diff --git a/docs/requirements.txt b/docs/requirements.txt
index c979828..4ecb84c 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,3 +1,3 @@
 # Python dependencies to build the documentation
 
-Sphinx ~= 8.1
+Sphinx ~= 7.4
diff --git a/junitparser/junitparser.py b/junitparser/junitparser.py
index a91c6a0..bbf7046 100644
--- a/junitparser/junitparser.py
+++ b/junitparser/junitparser.py
@@ -514,6 +514,7 @@ class TestSuite(Element):
         super().__init__(self._tag)
         self.name = name
         self.filepath = None
+        self.root = JUnitXml
 
     def __iter__(self) -> Iterator[TestCase]:
         return itertools.chain(
@@ -552,7 +553,7 @@ class TestSuite(Element):
             result.update_statistics()
         else:
             # Create a new test result containing two testsuites
-            result = JUnitXml()
+            result = self.root()
             result.add_testsuite(self)
             result.add_testsuite(other)
         return result
@@ -566,7 +567,7 @@ class TestSuite(Element):
             self.update_statistics()
             return self
 
-        result = JUnitXml()
+        result = self.root()
         result.filepath = self.filepath
         result.add_testsuite(self)
         result.add_testsuite(other)
@@ -653,8 +654,7 @@ class TestSuite(Element):
 
     def testsuites(self):
         """Iterate through all testsuites."""
-        for suite in self.iterchildren(TestSuite):
-            yield suite
+        yield from self.iterchildren(type(self))
 
     def write(self, file_or_filename: Optional[Union[str, IO]] = None, *, pretty: bool = False):
         write_xml(self, file_or_filename=file_or_filename, pretty=pretty)
@@ -696,7 +696,7 @@ class JUnitXml(Element):
         return len(list(self.__iter__()))
 
     def __add__(self, other):
-        result = JUnitXml()
+        result = type(self)()
         for suite in self:
             result.add_testsuite(suite)
         for suite in other:
@@ -708,7 +708,7 @@ class JUnitXml(Element):
             for suite in other:
                 self.add_testsuite(suite)
         elif other._elem.tag == "testsuite":
-            suite = TestSuite(name=other.name)
+            suite = self.testsuite(name=other.name)
             for case in other:
                 suite._add_testcase_no_update_stats(case)
             self.add_testsuite(suite)
@@ -743,25 +743,26 @@ class JUnitXml(Element):
         self.time = round(time, 3)
 
     @classmethod
-    def fromroot(cls, root_elem: Element):
+    def fromroot(cls, root_elem: Element) -> "JUnitXml":
         """Construct JUnit objects from an elementTree root element."""
-        if root_elem.tag == "testsuites":
-            instance = cls()
-        elif root_elem.tag == "testsuite":
-            instance = cls.testsuite()
-        else:
+        instance = cls()
+        if root_elem.tag == "testsuite":
+            testsuite_element = root_elem
+            root_elem = testsuite_element.makeelement("testsuites", {})
+            root_elem.append(testsuite_element)
+        if not root_elem.tag == "testsuites":
             raise JUnitXmlError("Invalid format.")
         instance._elem = root_elem
         return instance
 
     @classmethod
-    def fromstring(cls, text: Union[str, bytes]):
+    def fromstring(cls, text: Union[str, bytes]) -> "JUnitXml":
         """Construct JUnit objects from an XML string (str or bytes)."""
         root_elem = etree.fromstring(text)  # nosec
         return cls.fromroot(root_elem)
 
     @classmethod
-    def fromfile(cls, file: Union[str, IO], parse_func=None):
+    def fromfile(cls, file: Union[str, IO], parse_func=None) -> "JUnitXml":
         """
         Construct JUnit objects from an XML file.
 
diff --git a/junitparser/xunit2.py b/junitparser/xunit2.py
index 96f4ad6..4f60827 100644
--- a/junitparser/xunit2.py
+++ b/junitparser/xunit2.py
@@ -168,6 +168,10 @@ class TestSuite(junitparser.TestSuite):
 
     testcase = TestCase
 
+    def __init__(self, name=None):
+        super().__init__(name)
+        self.root = JUnitXml
+
     @property
     def system_out(self):
         """<system-out>"""
diff --git a/pyproject.toml b/pyproject.toml
index 316b56a..8f2b2a9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,38 @@
 [build-system]
-requires = ["setuptools >=44.0", "wheel >=0.37"]
-build-backend = "setuptools.build_meta:__legacy__"
+requires = ["setuptools >= 61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "junitparser"
+dynamic = ["version"]
+authors = [
+  { name="Weiwei Wang", email="gastlygem@gmail.com" },
+]
+description = "Manipulates JUnit/xUnit Result XML files"
+readme = "README.rst"
+requires-python = ">=3.6"
+dependencies = []
+classifiers = [
+  "Development Status :: 5 - Production/Stable",
+  "Intended Audience :: Developers",
+  "License :: OSI Approved :: Apache Software License",
+  "Topic :: Text Processing",
+  "Programming Language :: Python :: 3",
+]
+license = {text = "Apache-2.0"}
+keywords = ["junit", "xunit", "xml", "parser"]
+
+[project.urls]
+Homepage = "https://github.com/weiwei/junitparser"
+Issues = "https://github.com/weiwei/junitparser/issues"
+Documentation = "https://junitparser.readthedocs.io/"
+
+[project.scripts]
+junitparser = "junitparser.cli:main"
+
+[tool.setuptools]
+packages = ["junitparser"]
+license-files = ["LICENSE"]
+
+[tool.setuptools.dynamic]
+version = {attr = "junitparser.version"}
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 8b13789..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 3480374..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal=1
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 782ed1a..0000000
--- a/setup.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from setuptools import setup, find_packages
-import os
-
-from junitparser import version
-
-
-def read(fname):
-    try:
-        with open(os.path.join(os.path.dirname(__file__), fname)) as f:
-            return f.read()
-    except IOError:
-        return ""
-
-
-setup(
-    name="junitparser",
-    version=version,
-    description="Manipulates JUnit/xUnit Result XML files",
-    long_description=read("README.rst"),
-    classifiers=[
-        "Development Status :: 5 - Production/Stable",
-        "Intended Audience :: Developers",
-        "License :: OSI Approved :: Apache Software License",
-        "Topic :: Text Processing",
-        "Programming Language :: Python :: 3",
-    ],
-    url="https://github.com/weiwei/junitparser",
-    author="Weiwei Wang",
-    author_email="gastlygem@gmail.com",
-    license="Apache 2.0",
-    install_requires=[],
-    keywords="junit xunit xml parser",
-    packages=find_packages(exclude=["tests"]),
-    entry_points={"console_scripts": ["junitparser=junitparser.cli:main"]},
-    zip_safe=False,
-)


Test patch:
diff --git a/tests/test_fromfile.py b/tests/test_fromfile.py
index 31bcbee..605c39a 100644
--- a/tests/test_fromfile.py
+++ b/tests/test_fromfile.py
@@ -5,6 +5,7 @@ from io import StringIO
 from unittest import skipIf
 from junitparser import (
     TestCase,
+    TestSuite,
     Skipped,
     Failure,
     JUnitXmlError,
@@ -21,13 +22,19 @@ except ImportError:
 
 def do_test_fromfile(fromfile_arg):
     xml = JUnitXml.fromfile(fromfile_arg)
+    assert isinstance(xml, JUnitXml)
     suite1, suite2 = list(iter(xml))
+    assert isinstance(suite1, TestSuite)
+    assert isinstance(suite2, TestSuite)
     assert len(list(suite1.properties())) == 0
     assert len(list(suite2.properties())) == 3
     assert len(suite2) == 3
     assert suite2.name == "JUnitXmlReporter.constructor"
     assert suite2.tests == 3
     cases = list(suite2.iterchildren(TestCase))
+    assert isinstance(cases[0], TestCase)
+    assert isinstance(cases[1], TestCase)
+    assert isinstance(cases[2], TestCase)
     assert isinstance(cases[0].result[0], Failure)
     assert isinstance(cases[1].result[0], Skipped)
     assert len(cases[2].result) == 0
@@ -98,13 +105,19 @@ def test_fromfile_with_parser():
         os.path.join(os.path.dirname(__file__), "data/normal.xml"),
         parse_func=parse_func,
     )
+    assert isinstance(xml, JUnitXml)
     suite1, suite2 = list(iter(xml))
+    assert isinstance(suite1, TestSuite)
+    assert isinstance(suite2, TestSuite)
     assert len(list(suite1.properties())) == 0
     assert len(list(suite2.properties())) == 3
     assert len(suite2) == 3
     assert suite2.name == "JUnitXmlReporter.constructor"
     assert suite2.tests == 3
     cases = list(suite2.iterchildren(TestCase))
+    assert isinstance(cases[0], TestCase)
+    assert isinstance(cases[1], TestCase)
+    assert isinstance(cases[2], TestCase)
     assert isinstance(cases[0].result[0], Failure)
     assert isinstance(cases[1].result[0], Skipped)
     assert len(cases[2].result) == 0
@@ -114,20 +127,31 @@ def test_fromfile_without_testsuites_tag():
     xml = JUnitXml.fromfile(
         os.path.join(os.path.dirname(__file__), "data/no_suites_tag.xml")
     )
-    cases = list(iter(xml))
-    properties = list(iter(xml.properties()))
-    assert len(properties) == 3
+    assert isinstance(xml, JUnitXml)
+    suites = list(iter(xml))
+    assert len(suites) == 1
+    suite = suites[0]
+    assert isinstance(suite, TestSuite)
+    assert suite.name == "JUnitXmlReporter.constructor"
+    assert suite.tests == 3
+    cases = list(iter(suite))
     assert len(cases) == 3
-    assert xml.name == "JUnitXmlReporter.constructor"
-    assert xml.tests == 3
+    assert isinstance(cases[0], TestCase)
+    assert isinstance(cases[1], TestCase)
+    assert isinstance(cases[2], TestCase)
     assert isinstance(cases[0].result[0], Failure)
     assert isinstance(cases[1].result[0], Skipped)
     assert len(cases[2].result) == 0
+    properties = list(iter(suite.properties()))
+    assert len(properties) == 3
 
 
 def test_fromfile_with_testsuite_in_testsuite():
     xml = JUnitXml.fromfile(os.path.join(os.path.dirname(__file__), "data/jenkins.xml"))
+    assert isinstance(xml, JUnitXml)
     suite1, suite2 = list(iter(xml))
+    assert isinstance(suite1, TestSuite)
+    assert isinstance(suite2, TestSuite)
     assert len(list(suite1.properties())) == 0
     assert len(list(suite2.properties())) == 3
     assert len(suite2) == 3
@@ -135,8 +159,11 @@ def test_fromfile_with_testsuite_in_testsuite():
     assert suite2.tests == 3
     direct_cases = list(suite2.iterchildren(TestCase))
     assert len(direct_cases) == 1
+    assert isinstance(direct_cases[0], TestCase)
     assert isinstance(direct_cases[0].result[0], Failure)
     all_cases = list(suite2)
+    assert isinstance(all_cases[0], TestCase)
+    assert isinstance(all_cases[1], TestCase)
     assert isinstance(all_cases[0].result[0], Failure)
     assert isinstance(all_cases[1].result[0], Skipped)
     assert len(all_cases[2].result) == 0
@@ -167,6 +194,9 @@ def test_multi_results_in_case():
    </testsuite>
 </testsuites>"""
     xml = JUnitXml.fromstring(text)
+    assert isinstance(xml, JUnitXml)
     suite = next(iter(xml))
+    assert isinstance(suite, TestSuite)
     case = next(iter(suite))
+    assert isinstance(case, TestCase)
     assert len(case.result) == 2
diff --git a/tests/test_general.py b/tests/test_general.py
index d5ea93e..6f650d3 100644
--- a/tests/test_general.py
+++ b/tests/test_general.py
@@ -127,6 +127,7 @@ class Test_JunitXml:
         <testcase name="testname2">
         </testcase></testsuite></testsuites>"""
         result = JUnitXml.fromstring(text)
+        assert isinstance(result, JUnitXml)
         assert len(result) == 2
         assert result.time == 0
 
@@ -135,6 +136,7 @@ class Test_JunitXml:
         <testcase name="testname1">
         </testcase></testsuite>"""
         result = JUnitXml.fromstring(text)
+        assert isinstance(result, JUnitXml)
         assert len(result) == 1
         assert result.time == 0
 
@@ -178,10 +180,14 @@ class Test_JunitXml:
         </testsuite>"""
         root_elemt = etree.fromstring(text)
         result = JUnitXml.fromroot(root_elemt)
-        assert isinstance(result, TestSuite)
-        assert result.errors == 1
-        assert result.skipped == 1
-        cases = list(iter(result))
+        assert isinstance(result, JUnitXml)
+        suites = list(iter(result))
+        assert len(suites) == 1
+        suite = suites[0]
+        assert isinstance(suite, TestSuite)
+        assert suite.errors == 1
+        assert suite.skipped == 1
+        cases = list(iter(suite))
         assert len(cases[0].result) == 0
         assert len(cases[1].result) == 2
         text = cases[1].result[1].text
@@ -203,6 +209,7 @@ class Test_JunitXml:
         </testsuites>"""
         root_elemt = etree.fromstring(text)
         result = JUnitXml.fromroot(root_elemt)
+        assert isinstance(result, JUnitXml)
         assert result.errors == 1
         assert result.skipped == 1
         suite = list(iter(result))[0]
diff --git a/tests/test_write.py b/tests/test_write.py
index add40a5..ab55a25 100644
--- a/tests/test_write.py
+++ b/tests/test_write.py
@@ -22,7 +22,7 @@ python_major = int(sys.version.split(".")[0])
 python_minor = int(sys.version.split(".")[1])
 
 
-def get_expected_xml(test_case_name: str, test_suites: bool = True):
+def get_expected_xml(test_case_name: str, test_suites: bool = True, newlines: bool = False):
     if python_major == 3 and python_minor <= 7 and not has_lxml:
         expected_test_suite = '<testsuite errors="0" failures="0" name="suite1" skipped="0" tests="1" time="0">'
     else:
@@ -42,9 +42,13 @@ def get_expected_xml(test_case_name: str, test_suites: bool = True):
         start_test_suites = ""
         end_test_suites = ""
 
+    eol = "\n" if newlines else ""
+    indent = " " if newlines else ""
     return (
         f"<?xml version='1.0' encoding='{encoding}'?>\n"
-        f'{start_test_suites}{expected_test_suite}<testcase name="{test_case_name}"{closing_tag}></testsuite>{end_test_suites}'
+        f'{start_test_suites}{expected_test_suite}{eol}'
+        f'{indent}<testcase name="{test_case_name}"{closing_tag}>{eol}'
+        f'</testsuite>{end_test_suites}'
     )
 
 
@@ -174,6 +178,31 @@ def test_write_nonascii():
     assert xmlfile.getvalue().decode("utf-8") == get_expected_xml("用例1")
 
 
+def test_write_no_testsuites():
+    # Has to be a binary string to include xml declarations.
+    text = b"""<?xml version='1.0' encoding='UTF-8'?>
+<testsuite name="suite1" tests="1" errors="0" failures="0" skipped="0" time="0">
+ <testcase name="case1"/>
+</testsuite>"""
+    xml = JUnitXml.fromstring(text)
+    assert isinstance(xml, JUnitXml)
+    suite = next(iter(xml))
+    assert isinstance(suite, TestSuite)
+    case = next(iter(suite))
+    assert isinstance(case, TestCase)
+    assert len(case.result) == 0
+
+    # writing this JUnitXml object contains a root <testsuites> element
+    xmlfile = BytesIO()
+    xml.write(xmlfile)
+    assert xmlfile.getvalue().decode("utf-8") == get_expected_xml("case1", test_suites=True, newlines=True)
+
+    # writing the inner testsuite reproduces the input string
+    xmlfile = BytesIO()
+    suite.write(xmlfile)
+    assert xmlfile.getvalue().decode("utf-8") == get_expected_xml("case1", test_suites=False, newlines=True)
+
+
 def test_read_written_xml():
     suite1 = TestSuite()
     suite1.name = "suite1"
diff --git a/tests/test_xunit2.py b/tests/test_xunit2.py
index 6499a52..13d439e 100644
--- a/tests/test_xunit2.py
+++ b/tests/test_xunit2.py
@@ -162,6 +162,7 @@ class Test_TestSuite:
         suite = TestSuite("mySuite")
         suite.add_testsuite(TestSuite("suite1"))
         suite = next(suite.testsuites())
+        assert isinstance(suite, TestSuite)
         assert suite.name == "suite1"
 
     def test_remove_case(self):
@@ -171,6 +172,20 @@ class Test_TestSuite:
         suite.remove_testcase(test)
         assert len(suite) == 0
 
+    def test_add_testsuites(self):
+        suite1 = TestSuite("suite1")
+        suite2 = TestSuite("suite2")
+        suites = suite1 + suite2
+        assert isinstance(suites, JUnitXml)
+        assert len(list(iter(suites))) == 2
+
+    def test_iadd_testsuites(self):
+        suite1 = TestSuite("suite1")
+        suite2 = TestSuite("suite2")
+        suite1 += suite2
+        assert isinstance(suite1, JUnitXml)
+        assert len(list(iter(suite1))) == 2
+
 
 class Test_JUnitXml:
     def test_init(self):
@@ -211,7 +226,11 @@ class Test_JUnitXml:
          <testcase name="test name 1"/>
          <testcase name="test name 2"/>
         </testsuite>"""
-        suite = JUnitXml.fromstring(text)
+        xml = JUnitXml.fromstring(text)
+        assert isinstance(xml, JUnitXml)
+        suites = list(xml)
+        assert len(suites) == 1
+        suite = suites[0]
         assert isinstance(suite, TestSuite)
         assert suite.name == "suite name"
         cases = list(suite)