PKScreener Testing Guide
This document provides comprehensive guidance for writing and running tests for PKScreener.
Table of Contents
Test Structure
Directory Layout
test/
├── asserters.py # Common assertion helpers
├── sharedmock.py # Shared mock objects
├── RequestsMocker.py # HTTP request mocking
│
├── globals_test.py # Tests for globals.py
├── pkscreenercli_test.py # CLI tests
├── pkscreenerbot_test.py # Bot tests
│
├── ScreeningStatistics_test.py # Screening logic tests
├── StockScreener_test.py # Screener orchestration tests
├── CandlePatterns_test.py # Pattern detection tests
│
├── BacktestHandler_test.py # Backtest tests
├── BacktestHandler_feature_test.py # Backtest feature tests
│
├── ResultsManager_test.py # Results formatting tests
├── TelegramNotifier_test.py # Notification tests
├── BotHandlers_feature_test.py # Bot handler tests
│
├── Fetcher_test.py # Data fetching tests
├── Configmanager_test.py # Configuration tests
│
├── MainLogic_comprehensive_test.py # Main logic tests
├── integration_mainlogic_test.py # Integration tests
│
└── ... # Other test files
Test File Naming Convention
Unit tests:
ModuleName_test.pyFeature tests:
ModuleName_feature_test.pyIntegration tests:
integration_modulename_test.py
Running Tests
Basic Commands
# Run all tests
pytest test/ -v
# Run specific test file
pytest test/ScreeningStatistics_test.py -v
# Run specific test class
pytest test/ScreeningStatistics_test.py::TestBreakoutValidation -v
# Run specific test method
pytest test/ScreeningStatistics_test.py::TestBreakoutValidation::test_breakout_detected -v
With Coverage
# Run with coverage measurement
coverage run -m pytest test/ -v
# Generate coverage report
coverage report --include="pkscreener/**"
# Generate HTML report
coverage html --include="pkscreener/**"
# Show missing lines
coverage report --show-missing --include="pkscreener/classes/StockScreener.py"
With Timeout
# Prevent hanging tests
pytest test/ --timeout=30 -v
Common Options
# Quiet mode (less output)
pytest test/ -q
# Stop on first failure
pytest test/ -x
# Show local variables on failure
pytest test/ -l
# Run in parallel (requires pytest-xdist)
pytest test/ -n auto
# Skip slow tests
pytest test/ -m "not slow"
Writing Tests
Basic Test Structure
"""
Tests for YourModule.py
"""
import pytest
import pandas as pd
import numpy as np
from unittest.mock import MagicMock, patch, Mock
from argparse import Namespace
class TestYourFeature:
"""Test suite for YourFeature."""
def setup_method(self):
"""Setup before each test method."""
self.mock_config = MagicMock()
self.mock_config.period = "1y"
self.mock_config.duration = "1d"
def teardown_method(self):
"""Cleanup after each test method."""
pass
def test_feature_positive_case(self):
"""Test description - what we're testing."""
# Arrange
input_data = self._create_test_data()
expected_result = True
# Act
result = your_function(input_data)
# Assert
assert result == expected_result
def test_feature_edge_case(self):
"""Test edge case handling."""
# Test with edge case data
pass
def _create_test_data(self):
"""Helper to create test data."""
return pd.DataFrame({
'Open': [100, 101, 102],
'High': [105, 106, 107],
'Low': [98, 99, 100],
'Close': [103, 104, 105],
'Volume': [1000, 1100, 1200]
})
Testing with Fixtures
import pytest
@pytest.fixture
def sample_ohlcv_data():
"""Create sample OHLCV DataFrame."""
dates = pd.date_range('2023-01-01', periods=100, freq='D')
return pd.DataFrame({
'Open': np.random.uniform(100, 110, 100),
'High': np.random.uniform(110, 120, 100),
'Low': np.random.uniform(90, 100, 100),
'Close': np.random.uniform(100, 115, 100),
'Volume': np.random.randint(1000, 10000, 100)
}, index=dates)
@pytest.fixture
def mock_config_manager():
"""Create mock ConfigManager."""
config = MagicMock()
config.period = "1y"
config.duration = "1d"
config.daysToLookback = 22
config.volumeRatio = 2.5
return config
@pytest.fixture
def mock_user_args():
"""Create mock user arguments."""
return Namespace(
options="X:12:1",
testbuild=True,
download=False,
log=False
)
class TestWithFixtures:
def test_using_fixtures(self, sample_ohlcv_data, mock_config_manager):
"""Test using pytest fixtures."""
# Use fixtures directly
assert len(sample_ohlcv_data) == 100
assert mock_config_manager.period == "1y"
Testing ScreeningStatistics
class TestBreakoutValidation:
"""Tests for validateBreakout method."""
def setup_method(self):
from pkscreener.classes.ScreeningStatistics import ScreeningStatistics
self.mock_config = MagicMock()
self.mock_config.daysToLookback = 22
self.mock_logger = MagicMock()
self.stats = ScreeningStatistics(
configManager=self.mock_config,
default_logger=self.mock_logger
)
def _create_breakout_data(self):
"""Create data that should trigger breakout."""
# Create consolidation followed by breakout
prices = [100] * 20 + [110, 115] # Consolidation then breakout
return pd.DataFrame({
'Open': prices,
'High': [p + 2 for p in prices],
'Low': [p - 2 for p in prices],
'Close': prices,
'Volume': [1000] * 20 + [5000, 6000] # Volume spike
})
def _create_no_breakout_data(self):
"""Create data that should not trigger breakout."""
prices = [100 + i*0.1 for i in range(22)] # Gradual rise
return pd.DataFrame({
'Open': prices,
'High': [p + 1 for p in prices],
'Low': [p - 1 for p in prices],
'Close': prices,
'Volume': [1000] * 22
})
def test_breakout_detected(self):
"""Test breakout is correctly detected."""
df = self._create_breakout_data()
screenDict = {}
saveDict = {}
result = self.stats.validateBreakout(df, screenDict, saveDict, 20)
assert result == True
assert 'Breakout' in screenDict.get('Pattern', '')
def test_no_breakout(self):
"""Test no false positive for non-breakout."""
df = self._create_no_breakout_data()
screenDict = {}
saveDict = {}
result = self.stats.validateBreakout(df, screenDict, saveDict, 20)
assert result == False
def test_with_exception_handling(self):
"""Test graceful handling of invalid data."""
df = pd.DataFrame() # Empty DataFrame
# Should not raise exception
result = self.stats.validateBreakout(df, {}, {}, 20)
assert result == False
Mocking Guidelines
Common Mocking Patterns
Mocking Output Controls
with patch('PKDevTools.classes.OutputControls.OutputControls.printOutput'):
# Code that prints output
result = function_that_prints()
Mocking User Input
with patch('builtins.input', return_value='Y'):
# Code that asks for input
result = function_that_asks()
Mocking External Services
with patch('pkscreener.classes.Fetcher.screenerStockDataFetcher.fetchStockDataWithArgs') as mock_fetch:
mock_fetch.return_value = (mock_stock_dict, {})
result = function_that_fetches()
Mocking Configuration
mock_config = MagicMock()
mock_config.period = "1y"
mock_config.duration = "1d"
mock_config.volumeRatio = 2.5
with patch('pkscreener.globals.configManager', mock_config):
result = function_using_config()
Mocking Telegram
with patch('PKDevTools.classes.Telegram.is_token_telegram_configured', return_value=True):
with patch('PKDevTools.classes.Telegram.send_message') as mock_send:
mock_send.return_value = MagicMock(text='Success')
result = send_notification()
Mocking Global State
import pkscreener.globals as gbl
def test_with_global_state():
original_args = gbl.userPassedArgs
original_choice = gbl.selectedChoice
try:
# Set test state
gbl.userPassedArgs = MagicMock()
gbl.userPassedArgs.options = "X:12:1"
gbl.selectedChoice = {"0": "X", "1": "12", "2": "1"}
# Run test
result = function_using_globals()
assert result is not None
finally:
# Restore original state
gbl.userPassedArgs = original_args
gbl.selectedChoice = original_choice
Mocking DataFrames
def create_mock_stock_data(trend='up', length=100):
"""Create mock stock data with specified trend."""
if trend == 'up':
close = [100 + i for i in range(length)]
elif trend == 'down':
close = [200 - i for i in range(length)]
else:
close = [100] * length
return pd.DataFrame({
'Open': [c - 1 for c in close],
'High': [c + 2 for c in close],
'Low': [c - 2 for c in close],
'Close': close,
'Volume': [1000 + i * 10 for i in range(length)],
'date': pd.date_range('2023-01-01', periods=length)
})
Test Coverage
Current Coverage Targets
Module |
Target |
Current |
|---|---|---|
90% |
90% ✓ |
|
90% |
90% ✓ |
|
90% |
94% ✓ |
|
90% |
93% ✓ |
|
90% |
93% ✓ |
|
90% |
100% ✓ |
|
90% |
45% |
|
90% |
69% |
|
90% |
57% |
Checking Coverage for Specific Files
# Check coverage for a specific file
coverage run -m pytest test/ScreeningStatistics_test.py
coverage report --include="pkscreener/classes/ScreeningStatistics.py" --show-missing
Coverage Report Interpretation
Name Stmts Miss Cover Missing
---------------------------------------------------------------------
pkscreener/classes/StockScreener.py 200 50 75% 45-60, 120-135
Stmts: Total statements
Miss: Uncovered statements
Cover: Coverage percentage
Missing: Line numbers not covered
Best Practices
1. Test Isolation
# Good: Each test is independent
class TestFeature:
def setup_method(self):
self.fresh_instance = MyClass()
def test_one(self):
result = self.fresh_instance.method()
assert result == expected
# Bad: Tests depend on each other
class TestFeature:
shared_state = None
def test_one(self):
TestFeature.shared_state = "value"
def test_two(self):
assert TestFeature.shared_state == "value" # Depends on test_one
2. Descriptive Test Names
# Good
def test_breakout_detected_when_price_exceeds_resistance():
pass
def test_rsi_returns_none_for_insufficient_data():
pass
# Bad
def test_1():
pass
def test_breakout():
pass
3. Arrange-Act-Assert Pattern
def test_volume_spike_detection(self):
# Arrange
df = self._create_volume_spike_data()
expected_spike = True
# Act
result = detect_volume_spike(df, threshold=2.0)
# Assert
assert result == expected_spike
4. Test Edge Cases
def test_empty_dataframe():
"""Test handling of empty input."""
result = process_data(pd.DataFrame())
assert result is None
def test_single_row():
"""Test with minimum data."""
df = pd.DataFrame({'Close': [100]})
result = calculate_sma(df, 20)
assert np.isnan(result)
def test_nan_values():
"""Test handling of NaN values."""
df = pd.DataFrame({'Close': [100, np.nan, 102]})
result = process_data(df)
assert result is not None
5. Use Parameterized Tests
import pytest
@pytest.mark.parametrize("input_value,expected", [
(0, "Zero"),
(1, "One"),
(-1, "Negative"),
(100, "Hundred"),
])
def test_number_to_text(input_value, expected):
result = number_to_text(input_value)
assert result == expected
6. Mock External Dependencies
# Good: Mock external API calls
@patch('requests.get')
def test_fetch_data(mock_get):
mock_get.return_value.json.return_value = {'data': [1, 2, 3]}
result = fetch_external_data()
assert result == [1, 2, 3]
# Bad: Actually calling external APIs in tests
def test_fetch_data():
result = fetch_external_data() # Flaky, slow, depends on network
See Also
DEVELOPER_GUIDE.md - Development setup
API_REFERENCE.md - API documentation
Contributing Guidelines - Contribution guidelines