PKScreener Testing Guide

This document provides comprehensive guidance for writing and running tests for PKScreener.


Table of Contents

  1. Test Structure

  2. Running Tests

  3. Writing Tests

  4. Mocking Guidelines

  5. Test Coverage

  6. Best Practices


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.py

  • Feature tests: ModuleName_feature_test.py

  • Integration 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

BacktestHandler.py

90%

90% ✓

ResultsManager.py

90%

90% ✓

TelegramNotifier.py

90%

94% ✓

BotHandlers.py

90%

93% ✓

PKUserRegistration.py

90%

93% ✓

Barometer.py

90%

100% ✓

globals.py

90%

45%

MainLogic.py

90%

69%

pkscreenercli.py

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