Repository: nose-devs/nose2

Issue:
Show parameter of subTest
```
class NumbersTest(unittest.TestCase):
    def test_even(self):
        """
        Test that numbers between 0 and 5 are all even.
        """
        for i in range(0, 6):
            with self.subTest(i=i):
                self.assertEqual(i % 2, 0)
```
https://docs.python.org/3.6/library/unittest.html

```
======================================================================
FAIL: test_even (__main__.NumbersTest) (i=1)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "subtests.py", line 32, in test_even
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0

======================================================================
FAIL: test_even (__main__.NumbersTest) (i=3)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "subtests.py", line 32, in test_even
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0

======================================================================
FAIL: test_even (__main__.NumbersTest) (i=5)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "subtests.py", line 32, in test_even
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0
```

As shown there, it can show the parameter of the subtests, which is not done. However it seems that it stoppes after first fail too.

Discussion:

Patch:
diff --git a/nose2/events.py b/nose2/events.py
index aa9d38c..bb02c8f 100644
--- a/nose2/events.py
+++ b/nose2/events.py
@@ -4,7 +4,9 @@
 # unittest2 is Copyright (c) 2001-2010 Python Software Foundation; All
 # Rights Reserved. See: http://docs.python.org/license.html
 
+import sys
 import logging
+import unittest
 
 import argparse
 import six
@@ -348,10 +350,15 @@ class Event(object):
                           for k in self._attrs])
 
     def __getstate__(self):
-        state = self.__dict__
+        state = self.__dict__.copy()
         # FIXME fails for loadTestsFailure
         if 'test' in state:
-            state['test'] = util.test_name(state['test'])
+            test = state['test']
+            state['test'] = util.test_name(test)
+            # subtest support
+            if sys.version_info >= (3, 4):
+                if isinstance(test, unittest.case._SubTest):
+                    state['metadata']['subtest'] = (test._message, test.params)
         if 'executeTests' in state:
             state['executeTests'] = None
         if 'exc_info' in state and state['exc_info'] is not None:
@@ -704,7 +711,7 @@ class TestOutcomeEvent(Event):
     .. attribute :: outcome
 
        Description of test outcome. Typically will be one of 'error',
-       'failed', 'skipped', or 'passed'.
+       'failed', 'skipped', 'passed', or 'subtest'.
 
     .. attribute :: exc_info
 
diff --git a/nose2/plugins/failfast.py b/nose2/plugins/failfast.py
index 894f40e..ed318ac 100644
--- a/nose2/plugins/failfast.py
+++ b/nose2/plugins/failfast.py
@@ -19,6 +19,11 @@ class FailFast(events.Plugin):
     commandLineSwitch = (
         'F', 'fail-fast', 'Stop the test run after the first error or failure')
 
+    def resultCreated(self, event):
+        """Mark new result"""
+        if hasattr(event.result, 'failfast'):
+            event.result.failfast = True
+
     def testOutcome(self, event):
         """Stop on unexpected error or failure"""
         if event.exc_info and not event.expected:
diff --git a/nose2/plugins/junitxml.py b/nose2/plugins/junitxml.py
index 7b9c4d3..47d693d 100644
--- a/nose2/plugins/junitxml.py
+++ b/nose2/plugins/junitxml.py
@@ -143,6 +143,10 @@ class JUnitXmlReporter(events.Plugin):
             test_args = ':'.join(testid_lines[1:])
             method = '%s (%s)' % (method, test_args)
 
+        # subtests do not report success
+        if event.outcome == result.SUBTEST and event.exc_info is None:
+            return
+
         testcase = ET.SubElement(self.tree, 'testcase')
         testcase.set('time', "%.6f" % self._time())
         if not classname:
@@ -186,6 +190,17 @@ class JUnitXmlReporter(events.Plugin):
             skipped = ET.SubElement(testcase, 'skipped')
             skipped.set('message', 'expected test failure')
             skipped.text = msg
+        elif event.outcome == result.SUBTEST:
+            if issubclass(event.exc_info[0], event.test.failureException):
+                self.failed += 1
+                failure = ET.SubElement(testcase, 'failure')
+                failure.set('message', 'test failure')
+                failure.text = msg
+            else:
+                self.errors += 1
+                error = ET.SubElement(testcase, 'error')
+                error.set('message', 'test failure')
+                error.text = msg
 
         system_out = ET.SubElement(testcase, 'system-out')
         system_out.text = string_cleanup(
diff --git a/nose2/plugins/mp.py b/nose2/plugins/mp.py
index 33f488e..689804b 100644
--- a/nose2/plugins/mp.py
+++ b/nose2/plugins/mp.py
@@ -274,6 +274,14 @@ class MultiProcess(events.Plugin):
                     'test_not_found',
                     RuntimeError("Unable to locate test case for %s in "
                                  "main process" % event.test))._tests[0]
+            # subtest support
+            if 'subtest' in event.metadata:
+                message, params = event.metadata.pop('subtest')
+                # XXX the sentinel value does not survive the pickle
+                # round-trip and must be reset by hand
+                if type(message) == type(object()):
+                    message = unittest.case._subtest_msg_sentinel
+                event.test = unittest.case._SubTest(event.test, message, params)
 
     def _exportSession(self):
         """
diff --git a/nose2/plugins/result.py b/nose2/plugins/result.py
index ec7ed70..89c7f73 100644
--- a/nose2/plugins/result.py
+++ b/nose2/plugins/result.py
@@ -90,6 +90,15 @@ class ResultReporter(events.Plugin):
             else:
                 self.reportCategories['unexpectedSuccesses'].append(event)
                 self._reportUnexpectedSuccess(event)
+        elif event.outcome == result.SUBTEST:
+            # subtests do not report success
+            if event.exc_info is not None:
+                if issubclass(event.exc_info[0], event.test.failureException):
+                    self.reportCategories['failures'].append(event)
+                    self._reportFailure(event)
+                else:
+                    self.reportCategories['errors'].append(event)
+                    self._reportError(event)
         else:
             # generic outcome handling
             self.reportCategories.setdefault(event.outcome, []).append(event)
@@ -115,7 +124,8 @@ class ResultReporter(events.Plugin):
         for name, events in self.reportCategories.items():
             for e in events:
                 if (e.outcome == result.ERROR or
-                    (e.outcome == result.FAIL and not e.expected)):
+                    (e.outcome == result.FAIL and not e.expected) or
+                    e.outcome == result.SUBTEST):
                     event.success = False
                     break
 
diff --git a/nose2/result.py b/nose2/result.py
index 21f9502..3d36700 100644
--- a/nose2/result.py
+++ b/nose2/result.py
@@ -7,6 +7,7 @@ ERROR = 'error'
 FAIL = 'failed'
 SKIP = 'skipped'
 PASS = 'passed'
+SUBTEST = 'subtest'
 __unittest = True
 
 
@@ -29,6 +30,8 @@ class PluggableTestResult(object):
     def __init__(self, session):
         self.session = session
         self.shouldStop = False
+        # XXX TestCase.subTest expects a result.failfast attribute
+        self.failfast = False
 
     def startTest(self, test):
         """Start a test case.
@@ -68,6 +71,16 @@ class PluggableTestResult(object):
         self.session.hooks.setTestOutcome(event)
         self.session.hooks.testOutcome(event)
 
+    def addSubTest(self, test, subtest, err):
+        """Called at the end of a subtest.
+
+        Fires :func:`setTestOutcome` and :func:`testOutcome` hooks.
+
+        """
+        event = events.TestOutcomeEvent(subtest, self, SUBTEST, err)
+        self.session.hooks.setTestOutcome(event)
+        self.session.hooks.testOutcome(event)
+
     def addSuccess(self, test):
         """Test case resulted in success.
 
diff --git a/nose2/util.py b/nose2/util.py
index 710b474..f083e9c 100644
--- a/nose2/util.py
+++ b/nose2/util.py
@@ -194,6 +194,9 @@ def test_name(test, qualname=True):
             tid = test.id()
     if '\n' in tid:
         tid = tid.split('\n')[0]
+    # subtest support
+    if ' ' in tid:
+        tid = tid.split(' ')[0]
     return tid
 
 


Test patch:
diff --git a/nose2/tests/functional/support/scenario/subtests/test_subtests.py b/nose2/tests/functional/support/scenario/subtests/test_subtests.py
new file mode 100644
index 0000000..974161e
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/subtests/test_subtests.py
@@ -0,0 +1,30 @@
+import unittest
+
+
+class Case(unittest.TestCase):
+
+    def test_subtest_success(self):
+        for i in range(3):
+            with self.subTest(i=i):
+                self.assertTrue(i < 3)
+
+    def test_subtest_failure(self):
+        for i in range(6):
+            with self.subTest(i=i):
+                self.assertEqual(i%2, 0)
+
+    def test_subtest_error(self):
+        for i in range(3):
+            with self.subTest(i=i):
+                raise RuntimeError(i)
+
+    @unittest.expectedFailure
+    def test_subtest_expected_failure(self):
+        for i in range(6):
+            with self.subTest(i=i):
+                self.assertEqual(i%2, 0)
+
+    def test_subtest_message(self):
+        for i in range(6):
+            with self.subTest('msg', i=i):
+                self.assertEqual(i%2, 0)
diff --git a/nose2/tests/functional/test_subtests.py b/nose2/tests/functional/test_subtests.py
new file mode 100644
index 0000000..17d0155
--- /dev/null
+++ b/nose2/tests/functional/test_subtests.py
@@ -0,0 +1,387 @@
+import sys
+import os
+import tempfile
+
+from xml.etree import ElementTree as ET
+from nose2.tests._common import FunctionalTestCase
+
+
+def read_report(path):
+    with open(path, 'r', encoding='utf-8') as f:
+        return ET.parse(f).getroot()
+
+
+class TestSubtests(FunctionalTestCase):
+
+    def setUp(self):
+        super(TestSubtests, self).setUp()
+        if sys.version_info < (3, 4):
+            self.skipTest('Python >= 3.4 required')
+
+    def test_success(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests.Case.test_subtest_success'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_success')
+        self.assertTestRunOutputMatches(proc, stderr='OK')
+        self.assertEqual(proc.poll(), 0)
+
+    def test_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests.Case.test_subtest_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_error(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests.Case.test_subtest_error'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=0\)')
+        self.assertTestRunOutputMatches(proc, stderr='RuntimeError: 0')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='RuntimeError: 1')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=2\)')
+        self.assertTestRunOutputMatches(proc, stderr='RuntimeError: 2')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(errors=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_expected_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests.Case.test_subtest_expected_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_expected_failure.*expected failure')
+        self.assertTestRunOutputMatches(proc, stderr='OK \(expected failures=1\)')
+        self.assertEqual(proc.poll(), 0)
+
+    def test_message(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests.Case.test_subtest_message'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_all(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 5 tests')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=0\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=2\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=6, errors=3, expected failures=1\)')
+        self.assertEqual(proc.poll(), 1)
+
+
+class TestSubtestsMultiprocess(FunctionalTestCase):
+
+    def setUp(self):
+        super(TestSubtestsMultiprocess, self).setUp()
+        if sys.version_info < (3, 4):
+            self.skipTest('Python >= 3.4 required')
+
+    def test_success(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.mp',
+            '--processes=2',
+            '-v',
+            'test_subtests.Case.test_subtest_success'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_success')
+        self.assertTestRunOutputMatches(proc, stderr='OK')
+        self.assertEqual(proc.poll(), 0)
+
+    def test_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.mp',
+            '--processes=2',
+            '-v',
+            'test_subtests.Case.test_subtest_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_error(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.mp',
+            '--processes=2',
+            '-v',
+            'test_subtests.Case.test_subtest_error'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=0\)')
+        self.assertTestRunOutputMatches(proc, stderr='RuntimeError: 0')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='RuntimeError: 1')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=2\)')
+        self.assertTestRunOutputMatches(proc, stderr='RuntimeError: 2')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(errors=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_expected_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.mp',
+            '--processes=2',
+            '-v',
+            'test_subtests.Case.test_subtest_expected_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_expected_failure.*expected failure')
+        self.assertTestRunOutputMatches(proc, stderr='OK \(expected failures=1\)')
+        self.assertEqual(proc.poll(), 0)
+
+    def test_message(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.mp',
+            '--processes=2',
+            '-v',
+            'test_subtests.Case.test_subtest_message'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_all(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.mp',
+            '--processes=2',
+            '-v',
+            'test_subtests'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 5 tests')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_message.*\[msg\] \(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=0\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_error.*\(i=2\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=6, errors=3, expected failures=1\)')
+        self.assertEqual(proc.poll(), 1)
+
+
+class TestSubtestsJunitXml(FunctionalTestCase):
+
+    def setUp(self):
+        super(TestSubtestsJunitXml, self).setUp()
+        if sys.version_info < (3, 4):
+            self.skipTest('Python >= 3.4 required')
+        tmp = tempfile.NamedTemporaryFile(delete=False)
+        tmp.close()
+        self.junit_report = tmp.name
+
+    def tearDown(self):
+        os.remove(self.junit_report)
+
+    def test_success(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.junitxml',
+            '--junit-xml',
+            '--junit-xml-path={}'.format(self.junit_report),
+            '-v',
+            'test_subtests.Case.test_subtest_success'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='OK')
+        self.assertEqual(proc.poll(), 0)
+
+        tree = read_report(self.junit_report)
+        self.assertEqual(tree.get('tests'), '1')
+        self.assertEqual(tree.get('failures'), '0')
+        self.assertEqual(tree.get('errors'), '0')
+        self.assertEqual(tree.get('skipped'), '0')
+        self.assertEqual(len(tree.findall('testcase')), 1)
+
+        for test_case in tree.findall('testcase'):
+            self.assertEqual(test_case.get('name'), 'test_subtest_success')
+
+    def test_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.junitxml',
+            '--junit-xml',
+            '--junit-xml-path={}'.format(self.junit_report),
+            '-v',
+            'test_subtests.Case.test_subtest_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED')
+        self.assertEqual(proc.poll(), 1)
+
+        tree = read_report(self.junit_report)
+        self.assertEqual(tree.get('tests'), '1')
+        self.assertEqual(tree.get('failures'), '3')
+        self.assertEqual(tree.get('errors'), '0')
+        self.assertEqual(tree.get('skipped'), '0')
+        self.assertEqual(len(tree.findall('testcase')), 3)
+
+        for index, test_case in enumerate(tree.findall('testcase')):
+            self.assertEqual(test_case.get('name'), 'test_subtest_failure (i={})'.format(index*2+1))
+
+    def test_error(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.junitxml',
+            '--junit-xml',
+            '--junit-xml-path={}'.format(self.junit_report),
+            '-v',
+            'test_subtests.Case.test_subtest_error'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED')
+        self.assertEqual(proc.poll(), 1)
+
+        tree = read_report(self.junit_report)
+        self.assertEqual(tree.get('tests'), '1')
+        self.assertEqual(tree.get('failures'), '0')
+        self.assertEqual(tree.get('errors'), '3')
+        self.assertEqual(tree.get('skipped'), '0')
+        self.assertEqual(len(tree.findall('testcase')), 3)
+
+        for index, test_case in enumerate(tree.findall('testcase')):
+            self.assertEqual(test_case.get('name'), 'test_subtest_error (i={})'.format(index))
+
+    def test_expected_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.junitxml',
+            '--junit-xml',
+            '--junit-xml-path={}'.format(self.junit_report),
+            '-v',
+            'test_subtests.Case.test_subtest_expected_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='OK')
+        self.assertEqual(proc.poll(), 0)
+
+        tree = read_report(self.junit_report)
+        self.assertEqual(tree.get('tests'), '1')
+        self.assertEqual(tree.get('failures'), '0')
+        self.assertEqual(tree.get('errors'), '0')
+        self.assertEqual(tree.get('skipped'), '1')
+        self.assertEqual(len(tree.findall('testcase')), 1)
+
+        for test_case in tree.findall('testcase'):
+            self.assertEqual(test_case.get('name'), 'test_subtest_expected_failure')
+
+    def test_message(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.junitxml',
+            '--junit-xml',
+            '--junit-xml-path={}'.format(self.junit_report),
+            '-v',
+            'test_subtests.Case.test_subtest_message'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED')
+        self.assertEqual(proc.poll(), 1)
+
+        tree = read_report(self.junit_report)
+        self.assertEqual(tree.get('tests'), '1')
+        self.assertEqual(tree.get('failures'), '3')
+        self.assertEqual(tree.get('errors'), '0')
+        self.assertEqual(tree.get('skipped'), '0')
+        self.assertEqual(len(tree.findall('testcase')), 3)
+
+        for index, test_case in enumerate(tree.findall('testcase')):
+            self.assertEqual(test_case.get('name'), 'test_subtest_message [msg] (i={})'.format(index*2+1))
+
+    def test_all(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--plugin=nose2.plugins.junitxml',
+            '--junit-xml',
+            '--junit-xml-path={}'.format(self.junit_report),
+            '-v',
+            'test_subtests'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 5 tests')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED')
+        self.assertEqual(proc.poll(), 1)
+
+        tree = read_report(self.junit_report)
+        self.assertEqual(tree.get('tests'), '5')
+        self.assertEqual(tree.get('failures'), '6')
+        self.assertEqual(tree.get('errors'), '3')
+        self.assertEqual(tree.get('skipped'), '1')
+        self.assertEqual(len(tree.findall('testcase')), 11)
+
+
+class TestSubtestsFailFast(FunctionalTestCase):
+
+    def setUp(self):
+        super(TestSubtestsFailFast, self).setUp()
+        if sys.version_info < (3, 4):
+            self.skipTest('Python >= 3.4 required')
+
+    def test_failure(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '-v',
+            'test_subtests.Case.test_subtest_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=3\)')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=5\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=3\)')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_failfast(self):
+        proc = self.runIn(
+            'scenario/subtests',
+            '--fail-fast',
+            '-v',
+            'test_subtests.Case.test_subtest_failure'
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(proc, stderr='test_subtest_failure.*\(i=1\)')
+        self.assertTestRunOutputMatches(proc, stderr='FAILED \(failures=1\)')
+        self.assertEqual(proc.poll(), 1)
diff --git a/nose2/tests/unit/test_util.py b/nose2/tests/unit/test_util.py
index e1deace..2dbb5da 100644
--- a/nose2/tests/unit/test_util.py
+++ b/nose2/tests/unit/test_util.py
@@ -1,5 +1,6 @@
 import os
 import sys
+import unittest
 
 from nose2.tests._common import TestCase
 from nose2 import util
@@ -14,3 +15,21 @@ class UtilTests(TestCase):
         self.assertNotIn(test_dir, sys.path)
         util.ensure_importable(test_dir)
         self.assertEqual(test_dir, sys.path[0])
+
+    def test_testcase_test_name(self):
+        test = UtilTests('test_ensure_importable')
+        self.assertEqual(test.id(),
+            'nose2.tests.unit.test_util.UtilTests.test_ensure_importable')
+        self.assertEqual(util.test_name(test),
+            'nose2.tests.unit.test_util.UtilTests.test_ensure_importable')
+
+    @unittest.skipIf(sys.version_info < (3, 4), 'Python >= 3.4 required')
+    def test_subtest_test_name(self):
+        from unittest.case import _SubTest
+        sentinel = getattr(unittest.case, '_subtest_msg_sentinel', None)
+        test = UtilTests('test_ensure_importable')
+        test = _SubTest(test, sentinel, {'i': 1, 'j': 2})
+        self.assertEqual(test.id(),
+            'nose2.tests.unit.test_util.UtilTests.test_ensure_importable (i=1, j=2)')
+        self.assertEqual(util.test_name(test),
+            'nose2.tests.unit.test_util.UtilTests.test_ensure_importable')