From ed43387778795e975cc82a0df1f87769010aef1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 16 Jan 2026 01:16:39 +0000 Subject: [PATCH] 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` --- beets/test/_common.py | 88 ++++++++++++++------------------- beets/test/helper.py | 17 ++----- test/conftest.py | 22 +++++++++ test/ui/commands/test_fields.py | 2 +- 4 files changed, 62 insertions(+), 67 deletions(-) diff --git a/beets/test/_common.py b/beets/test/_common.py index 10083c8cb..4de47c337 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -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. diff --git a/beets/test/helper.py b/beets/test/helper.py index 582d7ac43..809bc2a4e 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -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, diff --git a/test/conftest.py b/test/conftest.py index 059526d2f..b35083641 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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 diff --git a/test/ui/commands/test_fields.py b/test/ui/commands/test_fields.py index 0eaaa9ceb..98d4809c9 100644 --- a/test/ui/commands/test_fields.py +++ b/test/ui/commands/test_fields.py @@ -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)