Repository: qutebrowser/qutebrowser

Issue:
--temp-basedir can't delete basedir on Windows
```
C:\Users\florian\proj\qutebrowser>qutebrowser --temp-basedir :quit
Error in atexit._run_exitfuncs:
Traceback (most recent call last):
  File "C:\Python34\lib\shutil.py", line 371, in _rmtree_unsafe
    os.unlink(fullname)
PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'C:\\Users\\florian\\AppData\\Local\\Temp\\qutebrowser-basedir-dd4_6dpx\\cache\\WebpageIcons.db'
```



Discussion:

Resolving patch:
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -32,6 +32,7 @@
 import tokenize
 
 from PyQt5.QtWidgets import QApplication
+from PyQt5.QtWebKit import QWebSettings
 from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon, QCursor, QWindow
 from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QTimer, QUrl,
                           QObject, Qt, QEvent)
@@ -691,6 +692,8 @@ def _shutdown(self, status):
                     error.handle_fatal_exc(
                         e, self._args, "Error while saving!",
                         pre_text="Error while saving {}".format(key))
+        # Disable favicons so removing tempdir will work
+        QWebSettings.setIconDatabasePath("")
         # Re-enable faulthandler to stdout, then remove crash log
         log.destroy.debug("Deactivating crash log...")
         objreg.get('crash-handler').destroy_crashlogfile()
diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py
--- a/qutebrowser/misc/ipc.py
+++ b/qutebrowser/misc/ipc.py
@@ -226,12 +226,7 @@ def on_error(self, err):
         """Raise SocketError on fatal errors."""
         if self._socket is None:
             # Sometimes this gets called from stale sockets.
-            msg = "In on_error with None socket!"
-            if os.name == 'nt':  # pragma: no cover
-                # This happens a lot on Windows, so we ignore it there.
-                log.ipc.debug(msg)
-            else:
-                log.ipc.warning(msg)
+            log.ipc.debug("In on_error with None socket!")
             return
         self._timer.stop()
         log.ipc.debug("Socket error {}: {}".format(
@@ -275,7 +270,7 @@ def on_disconnected(self):
         log.ipc.debug("Client disconnected.")
         self._timer.stop()
         if self._socket is None:
-            log.ipc.warning("In on_disconnected with None socket!")
+            log.ipc.debug("In on_disconnected with None socket!")
         else:
             self._socket.deleteLater()
             self._socket = None


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -335,6 +335,10 @@ def func(code):
 @pytest.yield_fixture(autouse=True)
 def fail_tests_on_warnings():
     warnings.simplefilter('error')
+    # https://github.com/pytest-dev/pytest-bdd/issues/153
+    warnings.filterwarnings('ignore', message=r'inspect.getargspec\(\) is '
+                            r'deprecated, use inspect.signature\(\) instead',
+                            category=DeprecationWarning)
     yield
     warnings.resetwarnings()
 
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -17,6 +17,9 @@
 # You should have received a copy of the GNU General Public License
 # along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
 
+# pylint: disable=unused-import
+
 """Things needed for integration testing."""
 
-from webserver import httpbin, httpbin_clean  # pylint: disable=unused-import
+from webserver import httpbin, httpbin_after_test
+from quteprocess import quteproc
diff --git a/tests/integration/data/backforward/1.txt b/tests/integration/data/backforward/1.txt
new file mode 100644
--- /dev/null
+++ b/tests/integration/data/backforward/1.txt
@@ -0,0 +1 @@
+Hello
diff --git a/tests/integration/data/backforward/2.txt b/tests/integration/data/backforward/2.txt
new file mode 100644
--- /dev/null
+++ b/tests/integration/data/backforward/2.txt
@@ -0,0 +1 @@
+World
diff --git a/tests/integration/features/backforward.feature b/tests/integration/features/backforward.feature
new file mode 100644
--- /dev/null
+++ b/tests/integration/features/backforward.feature
@@ -0,0 +1,25 @@
+Feature: Going back and forward.
+    Testing the :back/:forward commands.
+
+    Scenario: Going back/forward
+        Given I open data/backforward/1.txt
+        When I open data/backforward/2.txt
+        And I run :back
+        And I reload
+        And I run :forward
+        And I reload
+        Then the requests should be:
+            data/backforward/1.txt
+            data/backforward/2.txt
+            data/backforward/1.txt
+            data/backforward/2.txt
+
+   Scenario: Going back without history
+       Given I open data/backforward/1.txt
+       When I run :back
+       Then the error "At beginning of history." should be shown.
+
+   Scenario: Going forward without history
+       Given I open data/backforward/1.txt
+       When I run :forward
+       Then the error "At end of history." should be shown.
diff --git a/tests/integration/features/test_features.py b/tests/integration/features/test_features.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/features/test_features.py
@@ -0,0 +1,74 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import logging
+
+import pytest
+
+if hasattr(sys, 'frozen'):
+    pytest.skip("test")
+else:
+    import pytest_bdd as bdd
+    bdd.scenarios('.')
+
+
+@bdd.given(bdd.parsers.parse("I set {sect} -> {opt} to {value}"))
+def set_setting(quteproc, sect, opt, value):
+    quteproc.set_setting(sect, opt, value)
+
+
+@bdd.given(bdd.parsers.parse("I open {path}"))
+def open_path(quteproc, path):
+    quteproc.open_path(path, new_tab=True)
+
+
+@bdd.when(bdd.parsers.parse("I open {path}"))
+def open_path_when(quteproc, path):
+    quteproc.open_path(path)
+
+
+@bdd.when(bdd.parsers.parse("I run {command}"))
+def run_command(quteproc, command):
+    quteproc.send_cmd(command)
+
+
+@bdd.when(bdd.parsers.parse("I reload"))
+def reload(qtbot, httpbin, quteproc, command):
+    with qtbot.waitSignal(httpbin.new_request, raising=True):
+        quteproc.send_cmd(':reload')
+
+
+@bdd.then(bdd.parsers.parse("{path} should be loaded"))
+def path_should_be_loaded(httpbin, path):
+    requests = httpbin.get_requests()
+    assert requests[-1] == httpbin.Request('GET', '/' + path)
+
+
+@bdd.then(bdd.parsers.parse("The requests should be:\n{pages}"))
+def list_of_loaded_pages(httpbin, pages):
+    requests = [httpbin.Request('GET', '/' + path.strip())
+                for path in pages.split('\n')]
+    assert httpbin.get_requests() == requests
+
+
+@bdd.then(bdd.parsers.parse('the error "{message}" should be shown.'))
+def expect_error(quteproc, message):
+    quteproc.mark_expected(category='message', loglevel=logging.ERROR,
+                           message=message)
diff --git a/tests/integration/quteprocess.py b/tests/integration/quteprocess.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/quteprocess.py
@@ -0,0 +1,234 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
+
+# pylint doesn't understand the testprocess import
+# pylint: disable=no-member
+
+"""Fixtures to run qutebrowser in a QProcess and communicate."""
+
+import re
+import sys
+import time
+import os.path
+import datetime
+import logging
+
+import pytest
+from PyQt5.QtCore import pyqtSignal
+
+import testprocess  # pylint: disable=import-error
+from qutebrowser.misc import ipc
+from qutebrowser.utils import log
+
+
+class NoLineMatch(Exception):
+
+    """Raised by LogLine on unmatched lines."""
+
+    pass
+
+
+class LogLine:
+
+    """A parsed line from the qutebrowser log output.
+
+    Attributes:
+        timestamp/loglevel/category/module/function/line/message:
+            Parsed from the log output.
+        _line: The entire unparsed line.
+        expected: Whether the message was expected or not.
+    """
+
+    LOG_RE = re.compile(r"""
+        (?P<timestamp>\d\d:\d\d:\d\d)
+        \ (?P<loglevel>VDEBUG|DEBUG|INFO|WARNING|ERROR)
+        \ +(?P<category>\w+)
+        \ +(?P<module>(\w+|Unknown\ module)):(?P<function>\w+):(?P<line>\d+)
+        \ (?P<message>.+)
+    """, re.VERBOSE)
+
+    def __init__(self, line):
+        self._line = line
+        match = self.LOG_RE.match(line)
+        if match is None:
+            raise NoLineMatch(line)
+        self.__dict__.update(match.groupdict())
+
+        self.timestamp = datetime.datetime.strptime(match.group('timestamp'),
+                                                    '%H:%M:%S')
+        loglevel = match.group('loglevel')
+        if loglevel == 'VDEBUG':
+            self.loglevel = log.VDEBUG_LEVEL
+        else:
+            self.loglevel = getattr(logging, loglevel)
+
+        self.category = match.group('category')
+
+        module = match.group('module')
+        if module == 'Unknown module':
+            self.module = None
+        else:
+            self.module = module
+
+        self.function = match.group('function')
+        self.line = int(match.group('line'))
+        self.message = match.group('message')
+
+        self.expected = self._is_ignored()
+
+    def __repr__(self):
+        return 'LogLine({!r})'.format(self._line)
+
+    def _is_ignored(self):
+        """Check if the message is listed in qt_log_ignore."""
+        regexes = pytest.config.getini('qt_log_ignore')
+        for regex in regexes:
+            if re.match(regex, self.message):
+                return True
+        return False
+
+
+class QuteProc(testprocess.Process):
+
+    """A running qutebrowser process used for tests.
+
+    Attributes:
+        _ipc_socket: The IPC socket of the started instance.
+        _httpbin: The HTTPBin webserver.
+    """
+
+    got_error = pyqtSignal()
+
+    def __init__(self, httpbin, parent=None):
+        super().__init__(parent)
+        self._httpbin = httpbin
+        self._ipc_socket = None
+
+    def _parse_line(self, line):
+        try:
+            log_line = LogLine(line)
+        except NoLineMatch:
+            if line.startswith('  '):
+                # Multiple lines in some log output...
+                return None
+            elif not line.strip():
+                return None
+            elif (line == "QWaitCondition: Destroyed while threads are still "
+                          "waiting"):
+                # Happens on Windows during exit sometimes
+                return None
+            else:
+                raise testprocess.InvalidLine
+
+        if (log_line.loglevel in ['INFO', 'WARNING', 'ERROR'] or
+                pytest.config.getoption('--verbose')):
+            print(line)
+
+        start_okay_message = ("load status for "
+                              "<qutebrowser.browser.webview.WebView tab_id=0 "
+                              "url='about:blank'>: LoadStatus.success")
+
+        if (log_line.category == 'ipc' and
+                log_line.message.startswith("Listening as ")):
+            self._ipc_socket = log_line.message.split(' ', maxsplit=2)[2]
+        elif (log_line.category == 'webview' and
+                log_line.message == start_okay_message):
+            self.ready.emit()
+        elif log_line.loglevel > logging.INFO:
+            self.got_error.emit()
+
+        return log_line
+
+    def _executable_args(self):
+        if hasattr(sys, 'frozen'):
+            executable = os.path.join(os.path.dirname(sys.executable),
+                                      'qutebrowser')
+            args = []
+        else:
+            executable = sys.executable
+            args = ['-m', 'qutebrowser']
+        args += ['--debug', '--no-err-windows', '--temp-basedir',
+                 'about:blank']
+        return executable, args
+
+    def after_test(self):
+        bad_msgs = [msg for msg in self._data
+                    if msg.loglevel > logging.INFO and not msg.expected]
+        super().after_test()
+        if bad_msgs:
+            text = 'Logged unexpected errors:\n\n' + '\n'.join(
+                str(e) for e in bad_msgs)
+            pytest.fail(text, pytrace=False)
+
+    def send_cmd(self, command):
+        assert self._ipc_socket is not None
+
+        ipc.send_to_running_instance(self._ipc_socket, [command],
+                                     target_arg='')
+        self.wait_for(category='commands', module='command', function='run',
+                      message='Calling *')
+        # Wait a bit in cause the command triggers any error.
+        time.sleep(0.5)
+
+    def set_setting(self, sect, opt, value):
+        self.send_cmd(':set "{}" "{}" "{}"'.format(sect, opt, value))
+        self.wait_for(category='config', message='Config option changed: *')
+
+    def open_path(self, path, new_tab=False):
+        url_loaded_pattern = re.compile(
+            r"load status for <qutebrowser.browser.webview.WebView tab_id=\d+ "
+            r"url='[^']+'>: LoadStatus.success")
+
+        url = 'http://localhost:{}/{}'.format(self._httpbin.port, path)
+        if new_tab:
+            self.send_cmd(':open -t ' + url)
+        else:
+            self.send_cmd(':open ' + url)
+        self.wait_for(category='webview', message=url_loaded_pattern)
+
+    def mark_expected(self, category=None, loglevel=None, message=None):
+        """Mark a given logging message as expected."""
+        found_message = False
+
+        # Search existing messages
+        for item in self._data:
+            if category is not None and item.category != category:
+                continue
+            elif loglevel is not None and item.loglevel != loglevel:
+                continue
+            elif message is not None and item.message != message:
+                continue
+            item.expected = True
+            found_message = True
+
+        # If there is none, wait for the message
+        if not found_message:
+            line = self.wait_for(category=category, loglevel=loglevel,
+                                 message=message)
+            line.expected = True
+
+
+@pytest.yield_fixture
+def quteproc(qapp, httpbin):
+    """Fixture for qutebrowser process."""
+    proc = QuteProc(httpbin)
+    proc.start()
+    yield proc
+    proc.terminate()
+    proc.after_test()
diff --git a/tests/integration/test_quteprocess.py b/tests/integration/test_quteprocess.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_quteprocess.py
@@ -0,0 +1,38 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Test the quteproc fixture used for tests."""
+
+import pytest
+
+
+def test_quteproc_error_message(qtbot, quteproc):
+    """Make sure the test fails with an unexpected error message."""
+    with qtbot.waitSignal(quteproc.got_error, raising=True):
+        quteproc.send_cmd(':message-error test')
+    # Usually we wouldn't call this from inside a test, but here we force the
+    # error to occur during the test rather than at teardown time.
+    with pytest.raises(pytest.fail.Exception):
+        quteproc.after_test()
+
+
+def test_qt_log_ignore(qtbot, quteproc):
+    """Make sure the test passes when logging a qt_log_ignore message."""
+    with qtbot.waitSignal(quteproc.got_error, raising=True):
+        quteproc.send_cmd(':message-error "SpellCheck: test"')
diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py
--- a/tests/integration/test_smoke.py
+++ b/tests/integration/test_smoke.py
@@ -33,3 +33,7 @@ def test_smoke():
     argv += ['--debug', '--no-err-windows', '--nowindow', '--temp-basedir',
              'about:blank', ':later 500 quit']
     subprocess.check_call(argv)
+
+
+def test_smoke_quteproc(quteproc):
+    pass
diff --git a/tests/integration/test_testprocess.py b/tests/integration/test_testprocess.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_testprocess.py
@@ -0,0 +1,112 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Test testprocess.Process."""
+
+import sys
+import contextlib
+import datetime
+
+import pytest
+from PyQt5.QtCore import QProcess
+
+import testprocess
+
+pytestmark = [pytest.mark.not_frozen]
+
+
+@contextlib.contextmanager
+def stopwatch(min_ms=None, max_ms=None):
+    if min_ms is None and max_ms is None:
+        raise ValueError("Using stopwatch with both min_ms/max_ms None does "
+                         "nothing.")
+    start = datetime.datetime.now()
+    yield
+    stop = datetime.datetime.now()
+    delta_ms = (stop - start).total_seconds() * 1000
+    if min_ms is not None:
+        assert delta_ms >= min_ms
+    if max_ms is not None:
+        assert delta_ms <= max_ms
+
+
+class Line:
+
+    def __init__(self, data):
+        self.data = data
+
+    def __repr__(self):
+        return 'Line({!r})'.format(self.data)
+
+
+class PythonProcess(testprocess.Process):
+
+    """A testprocess which runs the given Python code."""
+
+    def __init__(self):
+        super().__init__()
+        self.proc.setReadChannel(QProcess.StandardOutput)
+        self.code = None
+
+    def _parse_line(self, line):
+        print("LINE: {}".format(line))
+        if line.strip() == 'ready':
+            self.ready.emit()
+        return Line(line)
+
+    def _executable_args(self):
+        code = [
+            'import sys, time',
+            'print("ready")',
+            'sys.stdout.flush()',
+            self.code,
+            'sys.stdout.flush()',
+            'time.sleep(20)',
+        ]
+        return (sys.executable, ['-c', ';'.join(code)])
+
+
+class TestWaitFor:
+
+    @pytest.yield_fixture
+    def pyproc(self):
+        proc = PythonProcess()
+        yield proc
+        proc.terminate()
+
+    def test_successful(self, pyproc):
+        """Using wait_for with the expected text."""
+        pyproc.code = "import time; time.sleep(0.5); print('foobar')"
+        pyproc.start()
+        with stopwatch(min_ms=300):  # on Windows, this can be done faster...
+            pyproc.wait_for(data="foobar")
+
+    def test_other_text(self, pyproc):
+        """Test wait_for when getting some unrelated text."""
+        pyproc.code = "import time; time.sleep(0.1); print('blahblah')"
+        pyproc.start()
+        with pytest.raises(testprocess.WaitForTimeout):
+            pyproc.wait_for(data="foobar", timeout=500)
+
+    def test_no_text(self, pyproc):
+        """Test wait_for when getting no text at all."""
+        pyproc.code = "pass"
+        pyproc.start()
+        with pytest.raises(testprocess.WaitForTimeout):
+            pyproc.wait_for(data="foobar", timeout=100)
diff --git a/tests/integration/test_webserver.py b/tests/integration/test_webserver.py
--- a/tests/integration/test_webserver.py
+++ b/tests/integration/test_webserver.py
@@ -46,5 +46,5 @@ def test_httpbin(httpbin, qtbot, path, content, expected):
 
     data = response.read().decode('utf-8')
 
-    assert httpbin.get_requests() == [('GET', path)]
+    assert httpbin.get_requests() == [httpbin.Request('GET', path)]
     assert (content in data) == expected
diff --git a/tests/integration/testprocess.py b/tests/integration/testprocess.py
--- a/tests/integration/testprocess.py
+++ b/tests/integration/testprocess.py
@@ -19,11 +19,12 @@
 
 """Base class for a subprocess run for tests.."""
 
-import sys
-import os.path
+import re
+import fnmatch
 
 import pytestqt.plugin  # pylint: disable=import-error
-from PyQt5.QtCore import pyqtSlot, pyqtSignal, QProcess, QObject
+from PyQt5.QtCore import pyqtSlot, pyqtSignal, QProcess, QObject, QElapsedTimer
+from PyQt5.QtTest import QSignalSpy
 
 
 class InvalidLine(Exception):
@@ -33,6 +34,18 @@ class InvalidLine(Exception):
     pass
 
 
+class ProcessExited(Exception):
+
+    """Raised when the child process did exit."""
+
+    pass
+
+
+class WaitForTimeout(Exception):
+
+    """Raised when wait_for didn't get the expected message."""
+
+
 class Process(QObject):
 
     """Abstraction over a running test subprocess process.
@@ -40,15 +53,15 @@ class Process(QObject):
     Reads the log from its stdout and parses it.
 
     Signals:
+        ready: Emitted when the server finished starting up.
         new_data: Emitted when a new line was parsed.
     """
 
-    PROCESS_NAME = None
+    ready = pyqtSignal()
     new_data = pyqtSignal(object)
 
     def __init__(self, parent=None):
         super().__init__(parent)
-        assert self.PROCESS_NAME is not None
         self._invalid = False
         self._data = []
         self.proc = QProcess()
@@ -63,7 +76,7 @@ def _parse_line(self, line):
         raise NotImplementedError
 
     def _executable_args(self):
-        """Get the arguments to pass to the executable."""
+        """Get the executable and arguments to pass to it as a tuple."""
         raise NotImplementedError
 
     def _get_data(self):
@@ -78,13 +91,25 @@ def _get_data(self):
         self.read_log()
         return self._data
 
+    def _wait_signal(self, signal, timeout=5000, raising=True):
+        """Wait for a signal to be emitted.
+
+        Should be used in a contextmanager.
+        """
+        blocker = pytestqt.plugin.SignalBlocker(
+            timeout=timeout, raising=raising)
+        blocker.connect(signal)
+        return blocker
+
     @pyqtSlot()
     def read_log(self):
         """Read the log from the process' stdout."""
+        if not hasattr(self, 'proc'):
+            # I have no idea how this happens, but it does...
+            return
         while self.proc.canReadLine():
             line = self.proc.readLine()
-            line = bytes(line).decode('utf-8').rstrip('\r\n')
-            print(line)
+            line = bytes(line).decode('utf-8', errors='ignore').rstrip('\r\n')
 
             try:
                 parsed = self._parse_line(line)
@@ -93,33 +118,23 @@ def read_log(self):
                 print("INVALID: {}".format(line))
                 continue
 
-            print('parsed: {}'.format(parsed))
             if parsed is not None:
                 self._data.append(parsed)
                 self.new_data.emit(parsed)
 
     def start(self):
         """Start the process and wait until it started."""
-        blocker = pytestqt.plugin.SignalBlocker(timeout=5000, raising=True)
-        blocker.connect(self.ready)
-        with blocker:
+        with self._wait_signal(self.ready, timeout=20000):
             self._start()
 
     def _start(self):
         """Actually start the process."""
-        if hasattr(sys, 'frozen'):
-            executable = os.path.join(os.path.dirname(sys.executable),
-                                      self.PROCESS_NAME)
-            args = []
-        else:
-            executable = sys.executable
-            args = [os.path.join(os.path.dirname(__file__),
-                                 self.PROCESS_NAME + '.py')]
-
-        self.proc.start(executable, args + self._executable_args())
+        executable, args = self._executable_args()
+        self.proc.readyRead.connect(self.read_log)
+        self.proc.start(executable, args)
         ok = self.proc.waitForStarted()
         assert ok
-        self.proc.readyRead.connect(self.read_log)
+        assert self.is_running()
 
     def after_test(self):
         """Clean up data after each test.
@@ -131,7 +146,70 @@ def after_test(self):
         if self._invalid:
             raise InvalidLine
 
-    def cleanup(self):
+    def terminate(self):
         """Clean up and shut down the process."""
+        if not self.is_running():
+            raise ProcessExited
         self.proc.terminate()
         self.proc.waitForFinished()
+
+    def is_running(self):
+        """Check if the process is currently running."""
+        return self.proc.state() == QProcess.Running
+
+    def wait_for(self, timeout=5000, **kwargs):
+        """Wait until a given value is found in the data.
+
+        Keyword arguments to this function get interpreted as attributes of the
+        searched data. Every given argument is treated as a pattern which
+        the attribute has to match against.
+
+        The behavior of this method is slightly different depending on the
+        types of the filtered values:
+
+        - If the value is a string or bytes object and the expected value is
+          too, the pattern is treated as a fnmatch glob pattern.
+        - If the value is a string or bytes object and the expected value is a
+          compiled regex, it is used for matching.
+        - If the value is any other type, == is used.
+
+        Return:
+            The matched line.
+        """
+
+        # FIXME make this a context manager which inserts a marker in
+        # self._data in __enter__ and checks if the signal already did arrive
+        # after marker in __exit__, and if not, waits?
+
+        regex_type = type(re.compile(''))
+
+        spy = QSignalSpy(self.new_data)
+        elapsed_timer = QElapsedTimer()
+        elapsed_timer.start()
+
+        while True:
+            got_signal = spy.wait(timeout)
+            if not got_signal or elapsed_timer.hasExpired(timeout):
+                raise WaitForTimeout
+
+            for args in spy:
+                assert len(args) == 1
+                line = args[0]
+
+                matches = []
+
+                for key, expected in kwargs.items():
+                    if expected is None:
+                        continue
+
+                    value = getattr(line, key)
+
+                    if isinstance(expected, regex_type):
+                        matches.append(expected.match(value))
+                    elif isinstance(value, (bytes, str)):
+                        matches.append(fnmatch.fnmatchcase(value, expected))
+                    else:
+                        matches.append(value == expected)
+
+                if all(matches):
+                    return line
diff --git a/tests/integration/webserver.py b/tests/integration/webserver.py
--- a/tests/integration/webserver.py
+++ b/tests/integration/webserver.py
@@ -23,7 +23,9 @@
 """Fixtures for the httpbin webserver."""
 
 import re
+import sys
 import socket
+import os.path
 import collections
 
 import pytest
@@ -32,7 +34,7 @@
 import testprocess  # pylint: disable=import-error
 
 
-Request = collections.namedtuple('Request', 'verb, url')
+Request = collections.namedtuple('Request', 'verb, path')
 
 
 class HTTPBin(testprocess.Process):
@@ -45,12 +47,11 @@ class HTTPBin(testprocess.Process):
         LOG_RE: Used to parse the CLF log which httpbin outputs.
 
     Signals:
-        ready: Emitted when the server finished starting up.
         new_request: Emitted when there's a new request received.
     """
 
-    ready = pyqtSignal()
     new_request = pyqtSignal(Request)
+    Request = Request  # So it can be used from the fixture easily.
 
     LOG_RE = re.compile(r"""
         (?P<host>[^ ]*)
@@ -59,15 +60,13 @@ class HTTPBin(testprocess.Process):
         \ \[(?P<date>[^]]*)\]
         \ "(?P<request>
             (?P<verb>[^ ]*)
-            \ (?P<url>[^ ]*)
+            \ (?P<path>[^ ]*)
             \ (?P<protocol>[^ ]*)
         )"
         \ (?P<status>[^ ]*)
         \ (?P<size>[^ ]*)
     """, re.VERBOSE)
 
-    PROCESS_NAME = 'webserver_sub'
-
     def __init__(self, parent=None):
         super().__init__(parent)
         self.port = self._get_port()
@@ -83,9 +82,11 @@ def _get_port(self):
 
     def get_requests(self):
         """Get the requests to the server during this test."""
-        return self._get_data()
+        requests = self._get_data()
+        return [r for r in requests if r.path != '/favicon.ico']
 
     def _parse_line(self, line):
+        print(line)
         if line == (' * Running on http://127.0.0.1:{}/ (Press CTRL+C to '
                     'quit)'.format(self.port)):
             self.ready.emit()
@@ -97,10 +98,19 @@ def _parse_line(self, line):
         # FIXME do we need to allow other options?
         assert match.group('protocol') == 'HTTP/1.1'
 
-        return Request(verb=match.group('verb'), url=match.group('url'))
+        return Request(verb=match.group('verb'), path=match.group('path'))
 
     def _executable_args(self):
-        return [str(self.port)]
+        if hasattr(sys, 'frozen'):
+            executable = os.path.join(os.path.dirname(sys.executable),
+                                      'webserver_sub')
+            args = [str(self.port)]
+        else:
+            executable = sys.executable
+            py_file = os.path.join(os.path.dirname(__file__),
+                                   'webserver_sub.py')
+            args = [py_file, str(self.port)]
+        return executable, args
 
     def cleanup(self):
         """Clean up and shut down the process."""
@@ -118,7 +128,7 @@ def httpbin(qapp):
 
 
 @pytest.yield_fixture(autouse=True)
-def httpbin_clean(httpbin):
+def httpbin_after_test(httpbin):
     """Fixture to clean httpbin request list after each test."""
     yield
     httpbin.after_test()
diff --git a/tests/test_conftest.py b/tests/test_conftest.py
--- a/tests/test_conftest.py
+++ b/tests/test_conftest.py
@@ -21,6 +21,7 @@
 
 
 import warnings
+import inspect
 
 import pytest
 
@@ -38,3 +39,8 @@ def test_no_qapp(request):
 def test_fail_on_warnings():
     with pytest.raises(PendingDeprecationWarning):
         warnings.warn('test', PendingDeprecationWarning)
+
+
+def test_getargspec():
+    """Make sure the getargspec DeprecationWarning gets ignored."""
+    inspect.getargspec(lambda: None)
diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py
--- a/tests/unit/misc/test_ipc.py
+++ b/tests/unit/misc/test_ipc.py
@@ -615,21 +615,23 @@ def test_timeout(qtbot, caplog, qlocalsocket, ipc_server):
     assert caplog.records()[-1].message == "IPC connection timed out."
 
 
-@pytest.mark.parametrize('method, args', [
-    pytest.mark.posix(('on_error', [0])),
-    ('on_disconnected', []),
-    ('on_ready_read', []),
+@pytest.mark.parametrize('method, args, is_warning', [
+    pytest.mark.posix(('on_error', [0], False)),
+    ('on_disconnected', [], False),
+    ('on_ready_read', [], True),
 ])
-def test_ipcserver_socket_none(ipc_server, caplog, method, args):
+def test_ipcserver_socket_none(ipc_server, caplog, method, args, is_warning):
     func = getattr(ipc_server, method)
     assert ipc_server._socket is None
 
-    with caplog.atLevel(logging.WARNING):
+    if is_warning:
+        with caplog.atLevel(logging.WARNING):
+            func(*args)
+    else:
         func(*args)
 
-    records = caplog.records()
     msg = "In {} with None socket!".format(method)
-    assert records[-1].message == msg
+    assert msg in [r.message for r in caplog.records()]
 
 
 class TestSendOrListen: