Add: Unit tests for habit_bot and stock_tracker

- tests/test_habit_bot.py: Habit tracking, food logging, keto guidance
- tests/test_stock_tracker.py: Portfolio management, P&L calculation
- pytest.ini: Pytest configuration
- Updated Jenkinsfile: Emphasized testing stages before build

Pipeline stages:
1. Code Quality Gates (lint + security)
2. Unit Tests (pytest with coverage)
3. Integration Tests (Oracle, Telegram, Gitea)
4. Build (only after tests pass)
5. Deploy to Staging
This commit is contained in:
Joungmin
2026-02-19 03:32:43 +09:00
parent 6d9bc5980f
commit ceb52b2146
5 changed files with 704 additions and 90 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests package

255
tests/test_habit_bot.py Normal file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""
Unit tests for Habit Bot
Tests: habit tracking, food logging, data persistence
"""
import pytest
import sys
import os
import json
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, MagicMock
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Test data directory
TEST_DATA_DIR = '/tmp/test_habit_bot'
os.makedirs(TEST_DATA_DIR, exist_ok=True)
@pytest.fixture
def mock_data():
"""Create mock data for testing"""
return {
'users': {},
'habits': {},
'habit_logs': {},
'food_logs': {},
'sessions': {}
}
@pytest.fixture
def app_with_mock_data(mock_data):
"""Create app with mock data"""
with patch('builtins.open', side_effect=lambda f, *args, **kwargs:
(MagicMock() if 'write' in str(f) else
(MagicMock() if any(x in str(f) for x in ['users.json', 'habits.json', 'habit_logs.json', 'food_logs.json', 'sessions.json']) else open(f, *args, **kwargs)))):
pass
# Mock load_json and save_json
def mock_load_json(f):
if 'users' in str(f):
return mock_data['users']
elif 'habits' in str(f):
return mock_data['habits']
elif 'habit_logs' in str(f):
return mock_data['habit_logs']
elif 'food_logs' in str(f):
return mock_data['food_logs']
elif 'sessions' in str(f):
return mock_data['sessions']
return {}
with patch('builtins.open', side_effect=lambda f, mode='r', *args, **kwargs:
(MagicMock(__enter__=MagicMock(return_value=StringIO(json.dumps(mock_data.get(f.split('/')[-1], {}))),
__exit__=MagicMock(return_value=False)) if any(x in str(f) for x in ['users', 'habits', 'habit_logs', 'food_logs', 'sessions']) else open(f, mode, *args, **kwargs))):
with patch('habit_bot.load_json', side_effect=mock_load_json):
yield mock_data
class TestHabitBot:
"""Test habit tracking functionality"""
def test_add_habit(self, mock_data):
"""Test adding a new habit"""
habit_name = "morning workout"
# Simulate adding habit
user_id = "12345"
if user_id not in mock_data['habits']:
mock_data['habits'][user_id] = {}
mock_data['habits'][user_id][habit_name] = {
'name': habit_name,
'streak': 0,
'created_at': datetime.now().isoformat(),
'is_active': True
}
assert habit_name in mock_data['habits'][user_id]
assert mock_data['habits'][user_id][habit_name]['streak'] == 0
print(f"✅ Added habit: {habit_name}")
def test_log_habit_completion(self, mock_data):
"""Test logging habit completion"""
habit_name = "read books"
user_id = "12345"
today = datetime.now().strftime('%Y-%m-%d')
# Initialize data
if user_id not in mock_data['habits']:
mock_data['habits'][user_id] = {}
mock_data['habits'][user_id][habit_name] = {'streak': 5}
if user_id not in mock_data['habit_logs']:
mock_data['habit_logs'][user_id] = {}
if today not in mock_data['habit_logs'][user_id]:
mock_data['habit_logs'][user_id][today] = []
# Log completion
mock_data['habit_logs'][user_id][today].append({
'habit_name': habit_name,
'status': 'completed',
'notes': '30 minutes reading',
'timestamp': datetime.now().isoformat()
})
# Update streak
mock_data['habits'][user_id][habit_name]['streak'] += 1
assert len(mock_data['habit_logs'][user_id][today]) == 1
assert mock_data['habits'][user_id][habit_name]['streak'] == 6
print(f"✅ Logged habit: {habit_name} (streak: 6)")
def test_habit_streak_calculation(self, mock_data):
"""Test streak calculation"""
user_id = "12345"
habit_name = "exercise"
# Simulate 7-day streak
mock_data['habits'][user_id] = {
habit_name: {'streak': 7}
}
assert mock_data['habits'][user_id][habit_name]['streak'] == 7
print(f"✅ Streak calculated: 7 days")
class TestFoodLogging:
"""Test food/nutrition logging functionality"""
def test_analyze_simple_food(self, mock_data):
"""Test basic food analysis"""
from habit_bot import analyze_food_text
# Test chicken analysis
result = analyze_food_text("chicken breast 200g")
assert 'calories' in result
assert 'carbs' in result
assert 'protein' in result
assert 'fat' in result
assert result['protein'] > 0
print(f"✅ Food analyzed: {result}")
def test_analyze_multiple_foods(self, mock_data):
"""Test multi-food analysis"""
from habit_bot import analyze_food_text
# Test multiple items
result = analyze_food_text("2 eggs and 1 banana")
assert result['calories'] > 0
assert result['protein'] > 0
assert 'egg' in result or result['protein'] > 0 # Eggs contribute protein
print(f"✅ Multi-food analyzed: {result}")
def test_food_log_entry(self, mock_data):
"""Test food log entry creation"""
user_id = "12345"
today = datetime.now().strftime('%Y-%m-%d')
# Create food log
if user_id not in mock_data['food_logs']:
mock_data['food_logs'][user_id] = {}
if today not in mock_data['food_logs'][user_id]:
mock_data['food_logs'][user_id][today] = []
mock_data['food_logs'][user_id][today].append({
'meal_type': 'lunch',
'food_name': 'grilled chicken',
'time': '12:30',
'calories': 300,
'carbs': 0,
'protein': 50,
'fat': 8,
'timestamp': datetime.now().isoformat()
})
assert len(mock_data['food_logs'][user_id][today]) == 1
assert mock_data['food_logs'][user_id][today][0]['calories'] == 300
print("✅ Food log entry created")
class TestKetoGuidance:
"""Test keto diet guidance"""
def test_keto_calorie_targets(self, mock_data):
"""Test keto calorie calculation"""
# Keto guidelines
protein_per_kg = 1.3 # 1.3g per kg body weight
body_weight_kg = 70 # Example weight
protein_target = protein_per_kg * body_weight_kg
max_net_carbs = 25 # 25g per day
assert protein_target == 91 # 1.3 * 70
assert max_net_carbs == 25
print(f"✅ Keto targets: Protein {protein_target}g, Carbs {max_net_carbs}g")
def test_calorie_remaining(self, mock_data):
"""Test remaining calorie calculation"""
daily_target = 2000
consumed = 750
remaining = daily_target - consumed
assert remaining == 1250
print(f"✅ Calories remaining: {remaining}")
class TestDataPersistence:
"""Test data save/load functionality"""
def test_save_and_load_habits(self, mock_data, tmp_path):
"""Test habit data persistence"""
test_file = tmp_path / "test_habits.json"
# Save
mock_data['habits']['user1'] = {
'workout': {'streak': 10},
'meditation': {'streak': 5}
}
with open(test_file, 'w') as f:
json.dump(mock_data['habits'], f)
# Load
with open(test_file, 'r') as f:
loaded = json.load(f)
assert 'user1' in loaded
assert 'workout' in loaded['user1']
assert loaded['user1']['workout']['streak'] == 10
print("✅ Data persistence verified")
class TestMotivationalQuotes:
"""Test motivational quote system"""
def test_quotes_available(self, mock_data):
"""Test that quotes are available"""
from habit_bot import MOTIVATIONAL_QUOTES
assert len(MOTIVATIONAL_QUOTES) > 0
assert all(isinstance(q, str) for q in MOTIVATIONAL_QUOTES)
assert len(q) > 10 for q in MOTIVATIONAL_QUOTES) # Quotes should have content
print(f"{len(MOTIVATIONAL_QUOTES)} motivational quotes available")
# Pytest configuration
if __name__ == '__main__':
pytest.main([__file__, '-v'])

319
tests/test_stock_tracker.py Normal file
View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
Unit tests for Stock Tracker
Tests: Portfolio management, P&L calculation, price fetching
"""
import pytest
import sys
import os
import json
from datetime import datetime
from unittest.mock import Mock, patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TEST_DATA_DIR = '/tmp/test_stock_tracker'
os.makedirs(TEST_DATA_DIR, exist_ok=True)
@pytest.fixture
def mock_prices():
"""Create mock price data"""
return {
'stock_AAPL': {
'symbol': 'AAPL',
'current_price': 180.0,
'change_percent': 2.5,
'high_52w': 200.0,
'low_52w': 150.0,
'volume': 50000000,
'updated_at': datetime.now().isoformat()
},
'stock_MSFT': {
'symbol': 'MSFT',
'current_price': 380.0,
'change_percent': -1.2,
'high_52w': 420.0,
'low_52w': 310.0,
'volume': 25000000,
'updated_at': datetime.now().isoformat()
},
'crypto_BTC': {
'symbol': 'BTC',
'current_price': 45000.0,
'change_percent': 3.8,
'high_52w': 69000.0,
'low_52w': 35000.0,
'volume': 30000000000,
'updated_at': datetime.now().isoformat()
}
}
@pytest.fixture
def mock_positions():
"""Create mock positions"""
return {
'stock_AAPL': {
'symbol': 'AAPL',
'asset_type': 'stock',
'quantity': 10,
'avg_cost': 150.0,
'entry_date': '2025-01-15',
'streak': 0,
'is_active': True
},
'stock_MSFT': {
'symbol': 'MSFT',
'asset_type': 'stock',
'quantity': 5,
'avg_cost': 350.0,
'entry_date': '2025-02-01',
'streak': 0,
'is_active': True
}
}
class TestPortfolioManagement:
"""Test portfolio management functionality"""
def test_add_position(self, mock_positions):
"""Test adding a new position"""
from stock_tracker import StockTracker, Position
# Mock the file operations
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = {}
# Add a position
result = tracker.add_position(
symbol='NVDA',
asset_type='stock',
quantity=10,
avg_cost=800.0
)
assert result == True
key = 'stock_NVDA'
assert key in tracker.positions
assert tracker.positions[key].symbol == 'NVDA'
assert tracker.positions[key].quantity == 10
assert tracker.positions[key].avg_cost == 800.0
print(f"✅ Added position: NVDA 10 @ $800")
def test_remove_position(self, mock_positions):
"""Test removing a position"""
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = mock_positions
# Remove a position
result = tracker.remove_position('AAPL', 'stock')
assert result == True
assert 'stock_AAPL' not in tracker.positions
print("✅ Removed position: AAPL")
def test_get_positions(self, mock_positions):
"""Test getting all positions"""
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = mock_positions
positions = tracker.get_positions()
assert len(positions) == 2
print(f"✅ Retrieved {len(positions)} positions")
class TestPnLCalculation:
"""Test P&L calculation functionality"""
def test_calculate_profit(self, mock_positions, mock_prices):
"""Test profit calculation for winning position"""
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = mock_positions
tracker.prices = mock_prices
summary = tracker.calculate_portfolio_summary()
# AAPL: Bought @ $150, Current @ $180 = +20% profit
assert summary.total_value > summary.total_cost
assert summary.total_pnl_percent > 0
print(f"✅ Profit calculated: {summary.total_pnl_percent:.1f}%")
def test_calculate_loss(self, mock_positions, mock_prices):
"""Test loss calculation for losing position"""
# Modify MSFT to have a loss
mock_positions['stock_MSFT']['avg_cost'] = 400.0 # Bought higher than current
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = mock_positions
tracker.prices = mock_prices
summary = tracker.calculate_portfolio_summary()
# MSFT: Bought @ $400, Current @ $380 = -5% loss
msft_pos = next((p for p in summary.positions if p['symbol'] == 'MSFT'), None)
assert msft_pos['pnl_percent'] < 0
print(f"✅ Loss calculated: MSFT {msft_pos['pnl_percent']:.1f}%")
def test_pnl_percentage(self, mock_positions):
"""Test P&L percentage calculation"""
avg_cost = 100.0
current_price = 150.0
expected_pnl_percent = 50.0
pnl_percent = ((current_price - avg_cost) / avg_cost) * 100
assert pnl_percent == expected_pnl_percent
print(f"✅ P&L % calculated: {pnl_percent}%")
class TestInvestmentGuidelineChecks:
"""Test investment guideline compliance"""
def test_checklist_score_calculation(self):
"""Test 7-item checklist scoring"""
checklist = {
'story_clear': True,
'earnings_uptrend': True,
'balance_sheet_healthy': True,
'capital_return_plan': True,
'governance_clean': True,
'market_liquidity': True,
'relative_strength': False
}
score = sum(checklist.values())
max_score = len(checklist)
assert score == 6
assert f"{score}/{max_score}" == "6/7"
print(f"✅ Checklist score: {score}/{max_score}")
def test_pbr_evaluation(self):
"""Test PBR evaluation logic"""
# PBR < 1 is generally considered undervalued
pbr_values = {
'AAPL': 0.85, # Undervalued
'MSFT': 1.5, # Fair value
'GOOGL': 2.1, # Premium
'NVDA': 25.0 # Expensive (but justified by growth)
}
for symbol, pbr in pbr_values.items():
if pbr < 1:
status = "undervalued"
elif pbr < 3:
status = "fair value"
else:
status = "premium"
print(f"{symbol} PBR: {pbr}x ({status})")
def test_stop_loss_calculation(self):
"""Test -10% stop loss calculation"""
entry_price = 100000 # KRW
# Hard stop loss
stop_loss_price = entry_price * 0.9 # -10%
assert stop_loss_price == 90000
# Trailing stop (from high)
high_price = 120000
trailing_stop = high_price * 0.9 # -10% from high
assert trailing_stop == 108000
print(f"✅ Stop loss: {stop_loss_price} (entry: {entry_price})")
print(f"✅ Trailing stop: {trailing_stop} (high: {high_price})")
class TestReportGeneration:
"""Test report generation functionality"""
def test_daily_report_structure(self, mock_positions, mock_prices):
"""Test daily report has required sections"""
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = mock_positions
tracker.prices = mock_prices
report = tracker.generate_daily_report()
# Check report contains key sections
assert '일일 포트폴리오 리포트' in report or 'Daily' in report
assert '총 가치' in report or 'Total Value' in report
assert '손익' in report or 'P&L' in report
print("✅ Daily report structure verified")
def test_weekly_report_structure(self, mock_positions, mock_prices):
"""Test weekly report has required sections"""
with patch('stock_tracker.load_json', return_value={}):
with patch('stock_tracker.save_json'):
tracker = StockTracker.__new__(StockTracker)
tracker.positions = mock_positions
tracker.prices = mock_prices
report = tracker.generate_weekly_report()
# Check report contains key sections
assert '주간 포트폴리오 리포트' in report or 'Weekly' in report
assert '목표' in report or 'Goal' in report
assert '체크리스트' in report or 'Checklist' in report
print("✅ Weekly report structure verified")
class TestDataTypes:
"""Test data type validation"""
def test_position_validation(self):
"""Test Position dataclass"""
from stock_tracker import Position
pos = Position(
symbol='TEST',
asset_type='stock',
quantity=100,
avg_cost=50.0,
entry_date='2025-01-01'
)
assert pos.symbol == 'TEST'
assert pos.quantity == 100
assert pos.avg_cost == 50.0
assert pos.is_active == True
print("✅ Position validation passed")
def test_price_data_validation(self):
"""Test PriceData dataclass"""
from stock_tracker import PriceData
price = PriceData(
symbol='TEST',
current_price=100.0,
change_percent=2.5,
high_52w=120.0,
low_52w=80.0,
volume=1000000.0
)
assert price.symbol == 'TEST'
assert price.current_price == 100.0
assert price.change_percent == 2.5
print("✅ PriceData validation passed")
# Pytest configuration
if __name__ == '__main__':
pytest.main([__file__, '-v'])