Skip to content

Commit 6cbed56

Browse files
committed
feat(output): structured unittest support
1 parent 63e832b commit 6cbed56

File tree

7 files changed

+264
-27
lines changed

7 files changed

+264
-27
lines changed

lua/ultest/diagnostic/init.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ local function draw_buffer(file)
7979
local results = api.nvim_buf_get_var(bufnr, "ultest_results")
8080

8181
local valid_results = vim.tbl_filter(function(result)
82-
return result.error_line and result.error_message
82+
return type(result) == "table" and result.error_line and result.error_message
8383
end, results)
8484

8585
local diagnostics = create_diagnostics(bufnr, valid_results)

rplugin/python3/ultest/handler/parsers/output/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .base import ParseResult
77
from .parsec import ParseError
88
from .python.pytest import pytest_output
9+
from .python.unittest import unittest_output
910

1011

1112
@dataclass
@@ -17,10 +18,6 @@ class OutputPatterns:
1718

1819

1920
_BASE_PATTERNS = {
20-
"python#pyunit": OutputPatterns(
21-
failed_test=r"^FAIL: (?P<name>.*) \(.*?(?P<namespaces>\..+)\)",
22-
namespace_separator=r"\.",
23-
),
2421
"go#gotest": OutputPatterns(failed_test=r"^.*--- FAIL: (?P<name>.+?) "),
2522
"go#richgo": OutputPatterns(
2623
failed_test=r"^FAIL\s\|\s(?P<name>.+?) \(.*\)",
@@ -43,7 +40,10 @@ class OutputPatterns:
4340

4441
class OutputParser:
4542
def __init__(self, disable_patterns: List[str]) -> None:
46-
self._parsers = {"python#pytest": pytest_output}
43+
self._parsers = {
44+
"python#pytest": pytest_output,
45+
"python#pyunit": unittest_output,
46+
}
4747
self._patterns = {
4848
runner: patterns
4949
for runner, patterns in _BASE_PATTERNS.items()

rplugin/python3/ultest/handler/parsers/output/python/pytest.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from .. import parsec as p
22
from ..base import ParsedOutput, ParseResult
33
from ..parsec import generate
4-
5-
join_chars = lambda chars: "".join(chars)
4+
from ..util import join_chars, until_eol
65

76

87
@generate
@@ -107,19 +106,6 @@ def pytest_test_results_summary():
107106
return summary
108107

109108

110-
@generate
111-
def eol():
112-
new_line = yield p.string("\r\n") ^ p.string("\n")
113-
return new_line
114-
115-
116-
@generate
117-
def until_eol():
118-
text = yield p.many(p.exclude(p.any(), eol)).parsecmap(join_chars)
119-
yield eol
120-
return text
121-
122-
123109
@generate
124110
def failed_test_error_location():
125111
file_name = yield p.many1(p.none_of(" :")).parsecmap(join_chars)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from .. import parsec as p
2+
from ..base import ParsedOutput, ParseResult
3+
from ..parsec import generate
4+
from ..util import eol, join_chars, until_eol
5+
6+
7+
class ErroredTestError(Exception):
8+
...
9+
10+
11+
@generate
12+
def unittest_output():
13+
try:
14+
yield p.many(p.exclude(p.any(), failed_test_title))
15+
failed_tests = yield p.many1(failed_test)
16+
yield p.many(p.any())
17+
return ParsedOutput(results=failed_tests)
18+
except ErroredTestError:
19+
return ParsedOutput(results=[])
20+
21+
22+
@generate
23+
def failed_test():
24+
name, namespace = yield failed_test_title
25+
file, error_line = yield failed_test_traceback
26+
error_message = yield failed_test_error_message
27+
return ParseResult(
28+
name=name,
29+
namespaces=[namespace],
30+
file=file,
31+
message=error_message,
32+
line=error_line,
33+
)
34+
35+
36+
@generate
37+
def failed_test_title():
38+
text = (
39+
yield p.many1(p.string("="))
40+
>> eol
41+
>> (p.string("FAIL") ^ p.string("ERROR"))
42+
<< p.string(": ")
43+
)
44+
test = yield p.many1(p.none_of(" ")).parsecmap(join_chars)
45+
yield p.space()
46+
namespace = (
47+
yield (p.string("(") >> p.many1(p.none_of(")")) << (p.string(")") >> until_eol))
48+
.parsecmap(join_chars)
49+
.parsecmap(lambda s: s.split(".")[-1])
50+
)
51+
if namespace == "_FailedTest":
52+
# Can't infer namespace from file that couldn't be imported
53+
raise ErroredTestError
54+
yield p.many1(p.string("-")) >> eol
55+
return test, namespace
56+
57+
58+
@generate
59+
def traceback_location():
60+
file = (
61+
yield p.spaces()
62+
>> p.string('File "')
63+
>> p.many1(p.none_of('"')).parsecmap(join_chars)
64+
<< p.string('"')
65+
)
66+
line = yield (
67+
p.string(", line ")
68+
>> p.many1(p.digit()).parsecmap(join_chars).parsecmap(int)
69+
<< until_eol
70+
)
71+
return file, line
72+
73+
74+
@generate
75+
def failed_test_traceback():
76+
yield p.string("Traceback") >> until_eol
77+
file, line = yield traceback_location
78+
yield p.many1(p.string(" ") >> until_eol)
79+
return file, line
80+
81+
82+
@generate
83+
def failed_test_error_message():
84+
message = yield p.many1(p.exclude(until_eol, p.string("--") ^ p.string("==")))
85+
remove_index = len(message) - 0
86+
for line in reversed(message):
87+
if line != "":
88+
break
89+
remove_index -= 1
90+
return message[:remove_index] # Ends with blank lines
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from . import parsec as p
2+
from .parsec import generate
3+
4+
join_chars = lambda chars: "".join(chars)
5+
6+
7+
@generate
8+
def until_eol():
9+
text = yield p.many(p.exclude(p.any(), eol)).parsecmap(join_chars)
10+
yield eol
11+
return text
12+
13+
14+
@generate
15+
def eol():
16+
new_line = yield p.string("\r\n") ^ p.string("\n")
17+
return new_line

tests/mocks/test_outputs/pyunit

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,46 @@
1-
F.
1+
F3
2+
EF.F
23
======================================================================
3-
FAIL: test_d (test_a.TestMyClass)
4+
ERROR: test_c (test_a.TestClass)
45
----------------------------------------------------------------------
56
Traceback (most recent call last):
6-
File "/home/ronan/tests/test_a.py", line 9, in test_d
7-
assert 33 == 3
7+
File "/home/ronan/tests/test_a.py", line 37, in test_c
8+
a_function()
9+
File "/home/ronan/tests/tests/__init__.py", line 6, in a_function
10+
raise Exception
11+
Exception
12+
13+
======================================================================
14+
FAIL: test_b (test_a.TestClass)
15+
----------------------------------------------------------------------
16+
Traceback (most recent call last):
17+
File "/home/ronan/tests/test_a.py", line 34, in test_b
18+
self.assertEqual({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 5, "c": 3, "d": 4})
19+
AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}
20+
- {'a': 1, 'b': 2, 'c': 3}
21+
? ^
22+
23+
+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}
24+
? ^ ++++++++
25+
26+
27+
======================================================================
28+
FAIL: test_a (test_b.AnotherClass)
29+
----------------------------------------------------------------------
30+
Traceback (most recent call last):
31+
File "/home/ronan/tests/test_b.py", line 7, in test_a
32+
assert 2 == 3
33+
AssertionError
34+
35+
======================================================================
36+
FAIL: test_thing (tests.test_c.TestStuff)
37+
----------------------------------------------------------------------
38+
Traceback (most recent call last):
39+
File "/home/ronan/tests/tests/test_c.py", line 6, in test_thing
40+
assert False
841
AssertionError
942

1043
----------------------------------------------------------------------
11-
Ran 2 tests in 0.001s
44+
Ran 5 tests in 0.001s
1245

13-
FAILED (failures=1)
46+
FAILED (failures=3, errors=1)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from unittest import TestCase
2+
3+
from rplugin.python3.ultest.handler.parsers.output import OutputParser
4+
from rplugin.python3.ultest.handler.parsers.output.python.unittest import (
5+
ErroredTestError,
6+
ParseResult,
7+
failed_test,
8+
)
9+
from tests.mocks import get_output
10+
11+
12+
class TestUnittestParser(TestCase):
13+
def test_parse_failed_test(self):
14+
raw = """======================================================================
15+
FAIL: test_b (test_a.TestClass)
16+
----------------------------------------------------------------------
17+
Traceback (most recent call last):
18+
File "/home/ronan/tests/test_a.py", line 34, in test_b
19+
self.assertEqual({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 5, "c": 3, "d": 4})
20+
AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}
21+
- {'a': 1, 'b': 2, 'c': 3}
22+
? ^
23+
24+
+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}
25+
? ^ ++++++++
26+
27+
"""
28+
29+
expected = ParseResult(
30+
name="test_b",
31+
namespaces=["TestClass"],
32+
file="/home/ronan/tests/test_a.py",
33+
line=34,
34+
message=[
35+
"AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}",
36+
"- {'a': 1, 'b': 2, 'c': 3}",
37+
"? ^",
38+
"",
39+
"+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}",
40+
"? ^ ++++++++",
41+
],
42+
)
43+
result = failed_test.parse(raw)
44+
self.assertEqual(result, expected)
45+
46+
def test_parse_errored_test_raises(self):
47+
raw = """======================================================================
48+
ERROR: test_c (unittest.loader._FailedTest)
49+
----------------------------------------------------------------------
50+
ImportError: Failed to import test module: test_c
51+
Traceback (most recent call last):
52+
File "/home/ronan/.pyenv/versions/3.8.6/lib/python3.8/unittest/loader.py", line 436, in _find_test_path
53+
module = self._get_module_from_name(name)
54+
File "/home/ronan/.pyenv/versions/3.8.6/lib/python3.8/unittest/loader.py", line 377, in _get_module_from_name
55+
__import__(name)
56+
File "/home/ronan/tests/test_c.py", line 6, in <module>
57+
class CTests(TestCase):
58+
File "/home/ronan/tests/test_c.py", line 8, in CTests
59+
@not_a_decorator
60+
NameError: name 'not_a_decorator' is not defined
61+
62+
"""
63+
with self.assertRaises(ErroredTestError):
64+
failed_test.parse(raw)
65+
66+
def test_parse_unittest(self):
67+
parser = OutputParser([])
68+
raw = get_output("pyunit")
69+
result = parser.parse_failed("python#pyunit", raw)
70+
expected = [
71+
ParseResult(
72+
name="test_c",
73+
namespaces=["TestClass"],
74+
file="/home/ronan/tests/test_a.py",
75+
message=["Exception"],
76+
output=None,
77+
line=37,
78+
),
79+
ParseResult(
80+
name="test_b",
81+
namespaces=["TestClass"],
82+
file="/home/ronan/tests/test_a.py",
83+
message=[
84+
"AssertionError: {'a': 1, 'b': 2, 'c': 3} != {'a': 1, 'b': 5, 'c': 3, 'd': 4}",
85+
"- {'a': 1, 'b': 2, 'c': 3}",
86+
"? ^",
87+
"",
88+
"+ {'a': 1, 'b': 5, 'c': 3, 'd': 4}",
89+
"? ^ ++++++++",
90+
],
91+
output=None,
92+
line=34,
93+
),
94+
ParseResult(
95+
name="test_a",
96+
namespaces=["AnotherClass"],
97+
file="/home/ronan/tests/test_b.py",
98+
message=["AssertionError"],
99+
output=None,
100+
line=7,
101+
),
102+
ParseResult(
103+
name="test_thing",
104+
namespaces=["TestStuff"],
105+
file="/home/ronan/tests/tests/test_c.py",
106+
message=["AssertionError"],
107+
output=None,
108+
line=6,
109+
),
110+
]
111+
self.assertEqual(expected, result)

0 commit comments

Comments
 (0)