Skip to content

Commit

Permalink
Merge pull request #72 from jitseniesen/unittest
Browse files Browse the repository at this point in the history
Add support for unittest
  • Loading branch information
jitseniesen authored May 23, 2017
2 parents 3226435 + 61ac7a0 commit e826268
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 1 deletion.
105 changes: 105 additions & 0 deletions spyder_unittest/backend/tests/test_unittestrunner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2017 Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see LICENSE.txt for details)
"""Tests for unittestrunner.py"""

# Local imports
from spyder_unittest.backend.runnerbase import Category
from spyder_unittest.backend.unittestrunner import UnittestRunner


def test_unittestrunner_load_data():
output = """test_isupper (teststringmethods.TestStringMethods) ... ok
test_split (teststringmethods.TestStringMethods) ... ok
extra text\n"""
runner = UnittestRunner(None)
res = runner.load_data(output)
assert len(res) == 2

assert res[0].category == Category.OK
assert res[0].status == 'ok'
assert res[0].name == 'teststringmethods.TestStringMethods.test_isupper'
assert res[0].message == ''
assert res[0].extra_text == ''

assert res[1].category == Category.OK
assert res[1].status == 'ok'
assert res[1].name == 'teststringmethods.TestStringMethods.test_split'
assert res[1].message == ''
assert res[1].extra_text == 'extra text\n'


def test_unittestrunner_load_data_removes_footer():
output = """test1 (test_foo.Bar) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
"""
runner = UnittestRunner(None)
res = runner.load_data(output)
assert len(res) == 1
assert res[0].category == Category.OK
assert res[0].status == 'ok'
assert res[0].name == 'test_foo.Bar.test1'
assert res[0].extra_text == ''


def test_unittestrunner_load_data_with_exception():
output = """test1 (test_foo.Bar) ... FAIL
test2 (test_foo.Bar) ... ok
======================================================================
FAIL: test1 (test_foo.Bar)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/somepath/test_foo.py", line 5, in test1
self.assertEqual(1, 2)
AssertionError: 1 != 2
"""
runner = UnittestRunner(None)
res = runner.load_data(output)
assert len(res) == 2

assert res[0].category == Category.FAIL
assert res[0].status == 'FAIL'
assert res[0].name == 'test_foo.Bar.test1'
assert res[0].extra_text.startswith('Traceback')
assert res[0].extra_text.endswith('AssertionError: 1 != 2\n')

assert res[1].category == Category.OK
assert res[1].status == 'ok'
assert res[1].name == 'test_foo.Bar.test2'
assert res[1].extra_text == ''


def test_try_parse_header_with_ok():
runner = UnittestRunner(None)
text = 'test_isupper (testfoo.TestStringMethods) ... ok'
res = runner.try_parse_result(text)
assert res == ('test_isupper', 'testfoo.TestStringMethods', 'ok', '')


def test_try_parse_header_with_xfail():
runner = UnittestRunner(None)
text = 'test_isupper (testfoo.TestStringMethods) ... expected failure'
res = runner.try_parse_result(text)
assert res == ('test_isupper', 'testfoo.TestStringMethods',
'expected failure', '')


def test_try_parse_header_with_message():
runner = UnittestRunner(None)
text = "test_nothing (testfoo.Tests) ... skipped 'msg'"
res = runner.try_parse_result(text)
assert res == ('test_nothing', 'testfoo.Tests', 'skipped', 'msg')


def test_try_parse_header_starting_with_digit():
runner = UnittestRunner(None)
text = '0est_isupper (testfoo.TestStringMethods) ... ok'
res = runner.try_parse_result(text)
assert res is None
152 changes: 152 additions & 0 deletions spyder_unittest/backend/unittestrunner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2017 Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see LICENSE.txt for details)
"""Support for unittest framework."""

# Standard library imports
import re

# Third party imports
from qtpy.QtCore import QTextCodec
from spyder.py3compat import to_text_string

# Local imports
from spyder_unittest.backend.runnerbase import Category, RunnerBase, TestResult


class UnittestRunner(RunnerBase):
"""Class for running tests with unittest module in standard library."""

executable = 'python'

def create_argument_list(self):
"""Create argument list for testing process."""
return ['-m', 'unittest', 'discover', '-v']

def finished(self):
"""
Called when the unit test process has finished.
This function reads the results and emits `sig_finished`.
"""
qbytearray = self.process.readAllStandardOutput()
locale_codec = QTextCodec.codecForLocale()
output = to_text_string(locale_codec.toUnicode(qbytearray.data()))
testresults = self.load_data(output) # overrides base class method
self.sig_finished.emit(testresults, output)

def load_data(self, output):
"""
Read and parse output from unittest module.
Returns
-------
list of TestResult
Unit test results.
"""
res = []
lines = output.splitlines()
line_index = 0
test_index = None

while line_index < len(lines):
data = self.try_parse_result(lines[line_index])
if data:
name = data[1] + '.' + data[0]
if data[2] == 'ok':
cat = Category.OK
elif data[2] == 'FAIL' or data[2] == 'ERROR':
cat = Category.FAIL
else:
cat = Category.SKIP
tr = TestResult(cat, data[2], name, data[3], 0, '')
res.append(tr)
line_index += 1
test_index = -1
continue

data = self.try_parse_exception_header(lines, line_index)
if data:
line_index = data[0]
name = data[2] + '.' + data[1]
test_index = next(i for i, tr in enumerate(res)
if tr.name == name)

data = self.try_parse_footer(lines, line_index)
if data:
line_index = data
test_index = -1
continue

if test_index is not None:
text = res[test_index].extra_text + lines[line_index] + '\n'
res[test_index] = res[test_index]._replace(extra_text=text)
line_index += 1

return res

def try_parse_result(self, line):
"""
Try to parse a line of text as a test result.
Returns
-------
tuple of str or None
If line represents a test result, then return a tuple with four
strings: the name of the test function, the name of the test class,
the test result, and the reason (if no reason is given, the fourth
string is empty). Otherwise, return None.
"""
regexp = (r'([^\d\W]\w*) \(([^\d\W][\w.]*)\) \.\.\. '
'(ok|FAIL|ERROR|skipped|expected failure|unexpected success)'
"( '([^']*)')?\Z")
match = re.match(regexp, line)
if match:
msg = match.groups()[4] or ''
return match.groups()[:3] + (msg, )
else:
return None

def try_parse_exception_header(self, lines, line_index):
"""
Try to parse the header of an exception in unittest output.
Returns
-------
(int, str, str) or None
If an exception header is parsed successfully, then return a tuple
with the new line index, the name of the test function, and the
name of the test class. Otherwise, return None.
"""
if lines[line_index] != '':
return None
if not all(char == '=' for char in lines[line_index + 1]):
return None
regexp = r'\w+: ([^\d\W]\w*) \(([^\d\W][\w.]*)\)\Z'
match = re.match(regexp, lines[line_index + 2])
if not match:
return None
if not all(char == '-' for char in lines[line_index + 3]):
return None
return (line_index + 4, ) + match.groups()

def try_parse_footer(self, lines, line_index):
"""
Try to parse footer of unittest output.
Returns
-------
int or None
New line index if footer is parsed successfully, None otherwise
"""
if lines[line_index] != '':
return None
if not all(char == '-' for char in lines[line_index + 1]):
return None
if not re.match(r'^Ran [\d]+ tests? in', lines[line_index + 2]):
return None
if lines[line_index + 3] != '':
return None
return line_index + 5
34 changes: 34 additions & 0 deletions spyder_unittest/widgets/tests/test_unittestgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,37 @@ def test_run_tests_and_display_results(qtbot, tmpdir, monkeypatch, framework):
assert dt.topLevelItem(0).data(2, Qt.DisplayRole) == ''
assert dt.topLevelItem(1).data(0, Qt.DisplayRole) == 'failure'
assert dt.topLevelItem(1).data(1, Qt.DisplayRole) == 'test_foo.test_fail'


def test_run_tests_using_unittest_and_display_results(qtbot, tmpdir,
monkeypatch):
"""Basic check."""
os.chdir(tmpdir.strpath)
testfilename = tmpdir.join('test_foo.py').strpath

with open(testfilename, 'w') as f:
f.write("import unittest\n"
"class MyTest(unittest.TestCase):\n"
" def test_ok(self): self.assertEqual(1+1, 2)\n"
" def test_fail(self): self.assertEqual(1+1, 3)\n")

MockQMessageBox = Mock()
monkeypatch.setattr('spyder_unittest.widgets.unittestgui.QMessageBox',
MockQMessageBox)

widget = UnitTestWidget(None)
qtbot.addWidget(widget)
config = Config(wdir=tmpdir.strpath, framework='unittest')
with qtbot.waitSignal(widget.sig_finished, timeout=10000, raising=True):
widget.run_tests(config)

MockQMessageBox.assert_not_called()
itemcount = widget.datatree.topLevelItemCount()
assert itemcount == 2
data = lambda i, j: widget.datatree.topLevelItem(i).data(j, Qt.DisplayRole)
assert data(0, 0) == 'FAIL'
assert data(0, 1) == 'test_foo.MyTest.test_fail'
assert data(0, 2) == ''
assert data(1, 0) == 'ok'
assert data(1, 1) == 'test_foo.MyTest.test_ok'
assert data(1, 2) == ''
7 changes: 6 additions & 1 deletion spyder_unittest/widgets/unittestgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from spyder_unittest.backend.noserunner import NoseRunner
from spyder_unittest.backend.pytestrunner import PyTestRunner
from spyder_unittest.backend.runnerbase import Category
from spyder_unittest.backend.unittestrunner import UnittestRunner
from spyder_unittest.widgets.configdialog import Config, ask_for_config

# This is needed for testing this module as a stand alone script
Expand All @@ -46,7 +47,11 @@
}

# Supported testing framework
FRAMEWORKS = {'nose': NoseRunner, 'py.test': PyTestRunner}
FRAMEWORKS = {
'nose': NoseRunner,
'py.test': PyTestRunner,
'unittest': UnittestRunner
}


def is_unittesting_installed():
Expand Down

0 comments on commit e826268

Please sign in to comment.