Добавлены тесты для Cat, News, Morning когов, TextHelpCommand и truncate-функций (282 теста, все проходят)

This commit is contained in:
deadzilla 2026-07-09 13:07:09 +05:00
parent f417a9abbc
commit bc843abc63
6 changed files with 696 additions and 4 deletions

View File

@ -68,8 +68,12 @@ tests/ # pytest-тесты
test_fetch_weather.py # fetch_weather, fetch_open_meteo test_fetch_weather.py # fetch_weather, fetch_open_meteo
test_format_articles.py # truncate_title, _parse_date, format_articles test_format_articles.py # truncate_title, _parse_date, format_articles
test_commands_pg.py # Pg cog test_commands_pg.py # Pg cog
test_bot.py # инициализация бота test_commands_cat.py # Cat cog, команда !cat
test_commands_news.py # News cog, команда !nw
test_commands_morning.py # Morning cog, команда !morning
test_bot.py # инициализация бота, обработка ошибок запуска
test_morning_runner.py# тесты morning runner-а test_morning_runner.py# тесты morning runner-а
test_help_command.py # TextHelpCommand — текстовая справка по командам
test_logger.py # setup_logging — уровни, обработчики, формат test_logger.py # setup_logging — уровни, обработчики, формат
test_commands_status.py # команда !status — embed и uptime test_commands_status.py # команда !status — embed и uptime
test_commands_stats.py # команда !stats — подсчёт серверов и каналов test_commands_stats.py # команда !stats — подсчёт серверов и каналов
@ -106,14 +110,18 @@ python -m pytest tests/ -v
| `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 19 | | `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 19 |
| `test_format_articles.py` | `truncate_title()`, `_parse_date()`, `format_articles()` | 20 | | `test_format_articles.py` | `truncate_title()`, `_parse_date()`, `format_articles()` | 20 |
| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 | | `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 |
| `test_bot.py` | инициализация бота | 5 | | `test_commands_cat.py` | `Cat` cog, команда `!cat` (embed, fallback) | 8 |
| `test_commands_news.py` | `News` cog, команда `!nw` (статьи, посты, fallback) | 8 |
| `test_commands_morning.py` | `Morning` cog, команда `!morning` (run_morning) | 5 |
| `test_bot.py` | инициализация бота, обработка ошибок запуска | 5 |
| `test_morning_runner.py` | morning runner | 12 | | `test_morning_runner.py` | morning runner | 12 |
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 | | `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
| `test_help_command.py` | `TextHelpCommand` (справка, алиасы, скрытые команды) | 11 |
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 | | `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 | | `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 5 | | `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 5 |
| `test_integration.py` | загрузка когов, поток команд (моки API) | 9 | | `test_integration.py` | загрузка когов, поток команд (моки API) | 9 |
**Итого: 161 тест.** **Итого: 206 функций (282 тестов с учётом parametrized).**
## Запуск в Docker ## Запуск в Docker

136
tests/test_commands_cat.py Normal file
View File

@ -0,0 +1,136 @@
"""Тесты для commands/cat.py — команда !cat."""
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import discord
import pytest
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT_DIR))
def make_context() -> MagicMock:
"""Создать мокированный ctx."""
ctx = MagicMock()
ctx.author.name = "TestUser"
ctx.send = AsyncMock(return_value=None)
return ctx
class TestCatInit:
"""Тесты инициализации Cat cog."""
def test_cat_cog_instantiates(self) -> None:
"""Cat должен создаваться без параметров."""
from commands.cat import Cat
cog = Cat()
assert cog is not None
def test_cat_has_command(self) -> None:
"""Cat должен содержать команду cat."""
from commands.cat import Cat
cog = Cat()
assert cog.cat.name == "cat"
class TestCatCommand:
"""Тесты команды !cat."""
@pytest.mark.asyncio
async def test_cat_success(self) -> None:
"""Успешный ответ API -> embed с котиком."""
from commands.cat import Cat
cog = Cat()
ctx = make_context()
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
mock.return_value = "https://example.com/cat.jpg"
await cog.cat(cog, ctx)
ctx.send.assert_awaited_once()
embed = ctx.send.call_args[1]["embed"]
assert embed.title == "Котик для тебя!"
assert embed.image.url == "https://example.com/cat.jpg"
@pytest.mark.asyncio
async def test_cat_api_returns_none(self) -> None:
"""API вернул None -> fallback сообщение."""
from commands.cat import Cat
cog = Cat()
ctx = make_context()
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
mock.return_value = None
await cog.cat(cog, ctx)
ctx.send.assert_awaited_once()
content = ctx.send.call_args[0][0]
assert "Не удалось получить котика" in content
@pytest.mark.asyncio
async def test_cat_embed_color_orange(self) -> None:
"""Embed должен быть оранжевого цвета."""
from commands.cat import Cat
cog = Cat()
ctx = make_context()
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
mock.return_value = "https://example.com/cat.jpg"
await cog.cat(cog, ctx)
embed = ctx.send.call_args[1]["embed"]
assert embed.color == discord.Color.orange()
@pytest.mark.asyncio
async def test_cat_image_set_via_set_image(self) -> None:
"""URL должен быть установлен через embed.set_image."""
from commands.cat import Cat
cog = Cat()
ctx = make_context()
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
mock.return_value = "https://example.com/cat.jpg"
await cog.cat(cog, ctx)
embed = ctx.send.call_args[1]["embed"]
assert embed.image is not None
assert embed.image.url == "https://example.com/cat.jpg"
@pytest.mark.asyncio
async def test_cat_with_special_url(self) -> None:
"""URL со спецсимволами должен корректно встраиваться."""
from commands.cat import Cat
cog = Cat()
ctx = make_context()
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
mock.return_value = "https://example.com/cat.jpg?size=large&format=webp"
await cog.cat(cog, ctx)
embed = ctx.send.call_args[1]["embed"]
assert embed.image.url == "https://example.com/cat.jpg?size=large&format=webp"
@pytest.mark.asyncio
async def test_cat_send_raises(self) -> None:
"""Ошибка при отправке — fetch_cat вызывался."""
from commands.cat import Cat
cog = Cat()
ctx = make_context()
ctx.send.side_effect = Exception("channel error")
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
mock.return_value = "https://example.com/cat.jpg"
with pytest.raises(Exception, match="channel error"):
await cog.cat(cog, ctx)
mock.assert_awaited_once()

View File

@ -0,0 +1,84 @@
"""Тесты для commands/morning.py — команда !morning."""
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT_DIR))
def make_context() -> MagicMock:
"""Создать мокированный ctx."""
ctx = MagicMock()
ctx.author.name = "TestUser"
ctx.send = AsyncMock(return_value=None)
ctx.bot = MagicMock()
ctx.channel = MagicMock()
ctx.channel.name = "test-channel"
return ctx
class TestMorningInit:
"""Тесты инициализации Morning cog."""
def test_morning_cog_instantiates(self) -> None:
"""Morning должен создаваться без параметров."""
from commands.morning import Morning
cog = Morning()
assert cog is not None
def test_morning_has_command(self) -> None:
"""Morning должен содержать команду morning."""
from commands.morning import Morning
cog = Morning()
assert cog.morning.name == "morning"
class TestMorningCommand:
"""Тесты команды !morning."""
@pytest.mark.asyncio
async def test_morning_calls_run_morning(self) -> None:
"""!morning должен вызвать run_morning."""
from commands.morning import Morning
cog = Morning()
ctx = make_context()
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
await cog.morning(cog, ctx)
mock_run.assert_awaited_once_with(ctx.bot, ctx.channel)
@pytest.mark.asyncio
async def test_morning_passes_bot_and_channel(self) -> None:
"""!morning передаёт ctx.bot и ctx.channel в run_morning."""
from commands.morning import Morning
cog = Morning()
ctx = make_context()
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
await cog.morning(cog, ctx)
call_args = mock_run.call_args
assert call_args[0][0] is ctx.bot
assert call_args[0][1] is ctx.channel
@pytest.mark.asyncio
async def test_morning_run_morning_raises(self) -> None:
"""Ошибка в run_morning должна пробрасываться."""
from commands.morning import Morning
cog = Morning()
ctx = make_context()
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
mock_run.side_effect = Exception("api error")
with pytest.raises(Exception, match="api error"):
await cog.morning(cog, ctx)

151
tests/test_commands_news.py Normal file
View File

@ -0,0 +1,151 @@
"""Тесты для commands/news.py — команда !nw."""
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT_DIR))
def make_context() -> MagicMock:
"""Создать мокированный ctx."""
ctx = MagicMock()
ctx.author.name = "TestUser"
ctx.send = AsyncMock(return_value=None)
return ctx
class TestNewsInit:
"""Тесты инициализации News cog."""
def test_news_cog_instantiates(self) -> None:
"""News должен создаваться без параметров."""
from commands.news import News
cog = News()
assert cog is not None
def test_news_has_command(self) -> None:
"""News должен содержать команду nw."""
from commands.news import News
cog = News()
assert cog.nw.name == "nw"
class TestNewsCommand:
"""Тесты команды !nw."""
def _make_articles(self, count: int = 3) -> list[dict]:
return [
{"title": f"Статья {i}", "link": f"https://habr.com/article/{i}", "pub_date": f"Mon, 28 May 2026 10:00:00 +0000", "creator": "author", "tags": []}
for i in range(1, count + 1)
]
@pytest.mark.asyncio
async def test_nw_success_articles_and_posts(self) -> None:
"""Успешный ответ API для статей и постов."""
from commands.news import News
cog = News()
ctx = make_context()
articles = self._make_articles(3)
posts = self._make_articles(2)
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
mock_rss.side_effect = [articles, posts]
await cog.nw(cog, ctx)
ctx.send.assert_awaited_once()
call_count = mock_rss.await_count
assert call_count == 2 # статьи + посты
@pytest.mark.asyncio
async def test_nw_articles_none(self) -> None:
"""fetch_rss вернул None для статей -> fallback сообщение."""
from commands.news import News
cog = News()
ctx = make_context()
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
mock_rss.return_value = None
await cog.nw(cog, ctx)
ctx.send.assert_awaited_once()
content = ctx.send.call_args[0][0]
assert "Не удалось получить новости" in content
@pytest.mark.asyncio
async def test_nw_empty_articles(self) -> None:
"""Пустой список статей -> 'Новостей пока нет'."""
from commands.news import News
cog = News()
ctx = make_context()
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
mock_rss.return_value = []
await cog.nw(cog, ctx)
ctx.send.assert_awaited_once()
content = ctx.send.call_args[0][0]
assert "Новостей пока нет" in content
@pytest.mark.asyncio
async def test_nw_posts_none(self) -> None:
"""fetch_rss вернул None для постов -> fallback в сообщение."""
from commands.news import News
cog = News()
ctx = make_context()
articles = self._make_articles(2)
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
mock_rss.side_effect = [articles, None]
await cog.nw(cog, ctx)
ctx.send.assert_awaited_once()
content = ctx.send.call_args[0][0]
assert "Не удалось получить новости" in content
@pytest.mark.asyncio
async def test_nw_posts_empty(self) -> None:
"""Пустой список постов -> 'Новостей пока нет' для постов."""
from commands.news import News
cog = News()
ctx = make_context()
articles = self._make_articles(2)
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
mock_rss.side_effect = [articles, []]
await cog.nw(cog, ctx)
ctx.send.assert_awaited_once()
content = ctx.send.call_args[0][0]
# Second "Новостей пока нет" for posts section
count = content.count("Новостей пока нет")
assert count >= 1
@pytest.mark.asyncio
async def test_nw_limits_articles_to_5(self) -> None:
"""Более 5 статей -> truncate_message обрежет."""
from commands.news import News
cog = News()
ctx = make_context()
articles = self._make_articles(20)
posts = self._make_articles(20)
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
mock_rss.side_effect = [articles, posts]
await cog.nw(cog, ctx)
ctx.send.assert_awaited_once()
content = ctx.send.call_args[0][0]
# truncate_message limits to 2000 chars for plain text
assert len(content) <= 2003 # 2000 + "..."

View File

@ -1,5 +1,12 @@
import pytest import pytest
from utils.news import format_articles, truncate_title, _parse_date from utils.news import (
format_articles,
truncate_embed_field,
truncate_embed_text,
truncate_message,
truncate_title,
_parse_date,
)
class TestTruncateTitle: class TestTruncateTitle:
@ -287,3 +294,93 @@ class TestFormatArticles:
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert len(result) == 6 # заголовок + 5 статей assert len(result) == 6 # заголовок + 5 статей
assert "Статья 5" not in result[5] assert "Статья 5" not in result[5]
class TestTruncateMessage:
"""Тесты функции truncate_message() — обрезка plain text сообщений."""
def test_short_message_unchanged(self) -> None:
"""Короткое сообщение не обрезается."""
text = "Короткое сообщение"
assert truncate_message(text) == text
def test_exact_limit_unchanged(self) -> None:
"""Текст ровно 2000 символов не обрезается."""
text = "A" * 2000
assert truncate_message(text) == text
def test_over_limit_truncated(self) -> None:
"""Текст больше 2000 символов обрезается."""
text = "A" * 2500
result = truncate_message(text)
assert len(result) == 2000
assert result.endswith("...")
def test_custom_max_len(self) -> None:
"""Кастомный max_len."""
text = "A" * 150
result = truncate_message(text, max_len=100)
assert len(result) == 100
assert result.endswith("...")
def test_under_custom_max_len(self) -> None:
"""Текст меньше кастомного max_len не обрезается."""
text = "A" * 50
result = truncate_message(text, max_len=100)
assert result == text
class TestTruncateEmbedText:
"""Тесты функции truncate_embed_text() — обрезка embed.description."""
def test_short_text_unchanged(self) -> None:
"""Короткий текст не обрезается."""
text = "Short"
assert truncate_embed_text(text) == text
def test_exact_limit_unchanged(self) -> None:
"""Текст ровно 4096 символов не обрезается."""
text = "A" * 4096
assert truncate_embed_text(text) == text
def test_over_limit_truncated(self) -> None:
"""Текст больше 4096 символов обрезается."""
text = "A" * 5000
result = truncate_embed_text(text)
assert len(result) == 4096
assert result.endswith("...")
def test_custom_max_len(self) -> None:
"""Кастомный max_len."""
text = "A" * 200
result = truncate_embed_text(text, max_len=100)
assert len(result) == 100
assert result.endswith("...")
class TestTruncateEmbedField:
"""Тесты функции truncate_embed_field() — обрезка embed field value."""
def test_short_text_unchanged(self) -> None:
"""Короткий текст не обрезается."""
text = "Short"
assert truncate_embed_field(text) == text
def test_exact_limit_unchanged(self) -> None:
"""Текст ровно 1024 символа не обрезается."""
text = "A" * 1024
assert truncate_embed_field(text) == text
def test_over_limit_truncated(self) -> None:
"""Текст больше 1024 символов обрезается."""
text = "A" * 2000
result = truncate_embed_field(text)
assert len(result) == 1024
assert result.endswith("...")
def test_custom_max_len(self) -> None:
"""Кастомный max_len."""
text = "A" * 150
result = truncate_embed_field(text, max_len=100)
assert len(result) == 100
assert result.endswith("...")

216
tests/test_help_command.py Normal file
View File

@ -0,0 +1,216 @@
"""Тесты для bot.TextHelpCommand — текстовая справка по командам."""
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT_DIR))
@pytest.fixture
def help_command() -> "bot.TextHelpCommand":
"""Создать экземпляр TextHelpCommand."""
import bot
return bot.TextHelpCommand()
class TestGetCommandSignature:
"""Тесты get_command_signature."""
def test_simple_command_no_signature(self, help_command) -> None:
"""Команда без параметров."""
cmd = MagicMock()
cmd.qualified_name = "pg"
cmd.signature = ""
result = help_command.get_command_signature(cmd)
assert result == "!pg "
def test_command_with_signature(self, help_command) -> None:
"""Команда с параметрами."""
cmd = MagicMock()
cmd.qualified_name = "search"
cmd.signature = "<query>"
result = help_command.get_command_signature(cmd)
assert result == "!search <query>"
def test_group_command(self, help_command) -> None:
"""Групповая команда."""
cmd = MagicMock()
cmd.qualified_name = "mod ban"
cmd.signature = "<user>"
result = help_command.get_command_signature(cmd)
assert result == "!mod ban <user>"
class TestSendBotHelp:
"""Тесты send_bot_help."""
@pytest.mark.asyncio
async def test_send_bot_help_shows_commands(self, help_command) -> None:
"""Должен показать список команд по когам."""
cmd = MagicMock()
cmd.name = "pg"
cmd.hidden = False
cmd.short_doc = "Прогноз погоды"
cog = MagicMock()
cog.qualified_name = "Pg"
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
mapping = {cog: [cmd], None: []}
await help_command.send_bot_help(mapping)
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "Доступные команды:" in message
assert "!pg - Прогноз погоды" in message
assert "!<название команды>" in message
@pytest.mark.asyncio
async def test_send_bot_help_skips_hidden(self, help_command) -> None:
"""Скрытые команды не должны показываться."""
cmd = MagicMock()
cmd.name = "hidden_cmd"
cmd.hidden = True
cmd.short_doc = "Скрытая"
cog = MagicMock()
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
mapping = {cog: [cmd]}
await help_command.send_bot_help(mapping)
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "hidden_cmd" not in message
@pytest.mark.asyncio
async def test_send_bot_help_skips_none_cog(self, help_command) -> None:
"""Команды без cog (None) должны пропускаться."""
cmd = MagicMock()
cmd.name = "built_in"
cmd.hidden = False
cmd.short_doc = "Встроенная"
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
mapping = {None: [cmd]}
await help_command.send_bot_help(mapping)
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "built_in" not in message
@pytest.mark.asyncio
async def test_send_bot_help_empty_doc(self, help_command) -> None:
"""Команда без doc -> пустая строка описания."""
cmd = MagicMock()
cmd.name = "cat"
cmd.hidden = False
cmd.short_doc = ""
cog = MagicMock()
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
mapping = {cog: [cmd]}
await help_command.send_bot_help(mapping)
message = destination.send.call_args[0][0]
assert "!cat - " in message
class TestSendCommandHelp:
"""Тесты send_command_help."""
@pytest.mark.asyncio
async def test_send_command_help_basic(self, help_command) -> None:
"""Базовая справка по команде."""
cmd = MagicMock()
cmd.qualified_name = "pg"
cmd.signature = ""
cmd.doc = "Прогноз погоды"
cmd.short_doc = "Прогноз погоды"
cmd.aliases = []
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
await help_command.send_command_help(cmd)
message = destination.send.call_args[0][0]
assert "!pg " in message
assert "Прогноз погоды" in message
@pytest.mark.asyncio
async def test_send_command_help_with_aliases(self, help_command) -> None:
"""Команда с алиасами."""
cmd = MagicMock()
cmd.qualified_name = "pg"
cmd.signature = ""
cmd.doc = "Погода"
cmd.short_doc = "Погода"
cmd.aliases = ["weather", "w"]
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
await help_command.send_command_help(cmd)
message = destination.send.call_args[0][0]
assert "!weather, !w" in message
class TestSendCogHelp:
"""Тесты send_cog_help."""
@pytest.mark.asyncio
async def test_send_cog_help(self, help_command) -> None:
"""Справка по когу."""
cmd = MagicMock()
cmd.name = "pg"
cmd.hidden = False
cmd.short_doc = "Погода"
cog = MagicMock()
cog.qualified_name = "Pg"
cog.get_commands.return_value = [cmd]
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
await help_command.send_cog_help(cog)
message = destination.send.call_args[0][0]
assert "[Pg]:" in message
assert "!pg - Погода" in message
class TestSendErrorMessage:
"""Тесты send_error_message."""
@pytest.mark.asyncio
async def test_send_error_message(self, help_command) -> None:
"""Сообщение об ошибке."""
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
await help_command.send_error_message("Command not found")
destination.send.assert_awaited_once_with("Command not found")