Replace custom stdio mocks with pytest io fixture

Create a centralised pytest fixture to provide controllable stdin and
captured stdout in all tests. Simplify DummyIO/DummyIn and remove the
custom DummyOut implementation and make use of pytest builtin fixtures.

Create a centralised pytest fixture to provide controllable stdin and
captured stdout that can be applied to any tests, regardless whether
they are based on pytest or unittest.

* `io` fixture can be used as a fixture in pytest-based tests
* `IOMixin` can be used to attach `io` attribute to any test class,
  including `unittest.TestCase`
This commit is contained in:
Šarūnas Nejus 2026-01-16 01:16:39 +00:00
parent 125313d539
commit ed43387778
No known key found for this signature in database
4 changed files with 62 additions and 67 deletions

View file

@ -14,10 +14,13 @@
"""Some common functionality for beets' test cases."""
from __future__ import annotations
import os
import sys
import unittest
from contextlib import contextmanager
from typing import TYPE_CHECKING
import beets
import beets.library
@ -28,6 +31,9 @@ from beets import importer, logging, util
from beets.ui import commands
from beets.util import syspath
if TYPE_CHECKING:
import pytest
beetsplug.__path__ = [
os.path.abspath(
os.path.join(
@ -118,77 +124,55 @@ def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
# Mock I/O.
class InputError(Exception):
def __init__(self, output=None):
self.output = output
def __str__(self):
msg = "Attempt to read with no input provided."
if self.output is not None:
msg += f" Output: {self.output!r}"
return msg
class DummyOut:
encoding = "utf-8"
def __init__(self):
self.buf = []
def write(self, s):
self.buf.append(s)
def get(self):
return "".join(self.buf)
def flush(self):
self.clear()
def clear(self):
self.buf = []
class InputError(IOError):
def __str__(self) -> str:
return "Attempt to read with no input provided."
class DummyIn:
encoding = "utf-8"
def __init__(self, out=None):
self.buf = []
self.reads = 0
self.out = out
def __init__(self) -> None:
self.buf: list[str] = []
def add(self, s):
def add(self, s: str) -> None:
self.buf.append(f"{s}\n")
def close(self):
def close(self) -> None:
pass
def readline(self):
def readline(self) -> str:
if not self.buf:
if self.out:
raise InputError(self.out.get())
else:
raise InputError()
self.reads += 1
raise InputError
return self.buf.pop(0)
class DummyIO:
"""Mocks input and output streams for testing UI code."""
"""Test helper that manages standard input and output."""
def __init__(self):
self.stdout = DummyOut()
self.stdin = DummyIn(self.stdout)
def __init__(
self,
monkeypatch: pytest.MonkeyPatch,
capteesys: pytest.CaptureFixture[str],
) -> None:
self._capteesys = capteesys
self.stdin = DummyIn()
def addinput(self, s):
self.stdin.add(s)
monkeypatch.setattr("sys.stdin", self.stdin)
def getoutput(self):
res = self.stdout.get()
self.stdout.clear()
return res
def addinput(self, text: str) -> None:
"""Simulate user typing into stdin."""
self.stdin.add(text)
def readcount(self):
return self.stdin.reads
def getoutput(self) -> str:
"""Get the standard output captured so far.
Note: it clears the internal buffer, so subsequent calls will only
return *new* output.
"""
# Using capteesys allows you to see output in the console if the test fails
return self._capteesys.readouterr().out
# Utility.

View file

@ -44,6 +44,7 @@ from tempfile import gettempdir, mkdtemp, mkstemp
from typing import Any, ClassVar
from unittest.mock import patch
import pytest
import responses
from mediafile import Image, MediaFile
@ -163,19 +164,9 @@ NEEDS_REFLINK = unittest.skipUnless(
)
@pytest.mark.usefixtures("io")
class IOMixin:
@cached_property
def io(self) -> _common.DummyIO:
return _common.DummyIO()
def setUp(self) -> None:
super().setUp()
patcher = patch.multiple(
"sys", stdin=self.io.stdin, stdout=self.io.stdout
)
patcher.start()
self.addCleanup(patcher.stop)
io: _common.DummyIO
class TestHelper(ConfigMixin):
@ -757,8 +748,6 @@ class TerminalImportSessionFixture(TerminalImportSession):
class TerminalImportMixin(IOMixin, ImportHelper):
"""Provides_a terminal importer for the import session."""
io: _common.DummyIO
def _get_import_session(self, import_dir: bytes) -> importer.ImportSession:
return TerminalImportSessionFixture(
self.lib,

View file

@ -5,6 +5,7 @@ import pytest
from beets.autotag.distance import Distance
from beets.dbcore.query import Query
from beets.test._common import DummyIO
from beets.test.helper import ConfigMixin
from beets.util import cached_classproperty
@ -60,3 +61,24 @@ def clear_cached_classproperty():
def config():
"""Provide a fresh beets configuration for a module, when requested."""
return ConfigMixin().config
@pytest.fixture
def io(
request: pytest.FixtureRequest,
monkeypatch: pytest.MonkeyPatch,
capteesys: pytest.CaptureFixture[str],
) -> DummyIO:
"""Fixture for tests that need controllable stdin and captured stdout.
This fixture builds a per-test ``DummyIO`` helper and exposes it to the
test. When used on a test class, it attaches the helper as ``self.io``
attribute to make it available to all test methods, including
``unittest.TestCase``-based ones.
"""
io = DummyIO(monkeypatch, capteesys)
if request.instance:
request.instance.io = io
return io

View file

@ -16,7 +16,7 @@ class FieldsTest(IOMixin, ItemInDBTestCase):
items = library.Item.all_keys()
albums = library.Album.all_keys()
output = self.io.stdout.get().split()
output = self.io.getoutput().split()
self.remove_keys(items, output)
self.remove_keys(albums, output)