Skip to content

Conversation

@weklund
Copy link
Contributor

@weklund weklund commented Jul 24, 2025

This is a working PR to incrementally improve the test coverage to a percentage that we want. I imagine once we reach this predefined goal, then adding that as a minimum level should fail future CI actions.

@weklund
Copy link
Contributor Author

weklund commented Jul 24, 2025

As of last commit we are at 75%

@weklund
Copy link
Contributor Author

weklund commented Jul 24, 2025

Now at 78%

I believe I've capture a lot of the easy wins for unit testing. I want to tackle logs.py errors.py and the mixins next.

Happy to receive feedback so far!

@Voyz
Copy link
Owner

Voyz commented Jul 25, 2025

Hey @weklund great initiative 👏

  1. Did you write these tests by hand or are they AI-generated? If you used AI, did you review it one by one?
  2. I thought we want to move away from unittest and towards pytest. We've established that both can live safely, so moving towards pytest for any new tests would make sense, right? Happy to discuss this if I'm misunderstanding something here
  3. I'm trying to adopt the Arrange, Act, Assert sections you've suggested. Possibly we could start using them with these new test here?

@weklund
Copy link
Contributor Author

weklund commented Jul 31, 2025

  1. Some of these are AI generated, and should be reviewed more :)
  2. No you're right... I already forgot ha. Let me fix that.
  3. No you're right!

Let's try this again :)

@weklund
Copy link
Contributor Author

weklund commented Jul 31, 2025

Updated subscription controller, will update the oauth tests next

@weklund
Copy link
Contributor Author

weklund commented Aug 11, 2025

@Voyz I did another pass here, and a slightly different approach. Used AI a bit, but instead of writing, I used it to refactor, reduce, and simplify what the tests were asserting. Curious on your thoughts here. Unfortunate the diff is pretty large, but I have reached 84% coverage now.

Looks like the next biggest gains would be from ws testing, but wanted to follow up on client work in a different PR.

Screenshot 2025-08-11 at 11 06 06

In addition I wanted to wait on adding through coverage to mixins.

Would love your thoughts here! :D

@weklund
Copy link
Contributor Author

weklund commented Aug 22, 2025

Some actions to take for this PR:

  • Review tests and ensure we mock as little as possible
  • See more test candidates for parameterize
  • Add no coverage where it makes sense

@weklund weklund marked this pull request as ready for review August 31, 2025 18:27
@weklund
Copy link
Contributor Author

weklund commented Aug 31, 2025

@Voyz Would love your thoughts :)

Copy link
Owner

@Voyz Voyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @weklund thanks for your patience! I'm done with the move, so got some time to look into your PR again. I've done the first round of review, but admittedly didn't make much progress and didn't get a chance to finish it yet. It is a little difficult to follow through the tests due to the way they are structured with nested fixtures and large parameterise lists. I've brought it up in several points, giving examples on how to structure them in a way that would make the tests more standalone and readable.

Additionally, there seem to be the cases of 'testing the mocks, not testing the functionality' we've discussed last time, as well as tests that mock what has already been mocked. You previously mentioned these were due to the fact that the original PR was written by your AI agent without you having reviewed it. Was this also the case here?

Comment on lines +12 to +15
# Default subscription configuration
DEFAULT_SUBSCRIPTION_RETRIES = 5
DEFAULT_SUBSCRIPTION_TIMEOUT = 2.0
DEFAULT_OPERATIONAL_LOCK_TIMEOUT = 60
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we parameterise them, then let's add these to var.py

Comment on lines +45 to +80
@pytest.fixture
def subscription_factory():
"""Factory for creating subscription data structures with common patterns."""
def create_subscription(
status=False,
data=None,
needs_confirmation=True,
subscription_processor=None,
channel_suffix=""
):
"""Create a subscription dictionary with standard structure."""
return {
'status': status,
'data': data or {'key': f'value{channel_suffix}'},
'needs_confirmation': needs_confirmation,
'subscription_processor': subscription_processor
}

# Pre-defined common subscription types
create_subscription.active = lambda processor=None, data=None: create_subscription(
status=True, data=data, needs_confirmation=True, subscription_processor=processor
)

create_subscription.inactive = lambda processor=None, data=None: create_subscription(
status=False, data=data, needs_confirmation=True, subscription_processor=processor
)

create_subscription.active_no_confirm = lambda processor=None, data=None: create_subscription(
status=True, data=data, needs_confirmation=False, subscription_processor=processor
)

create_subscription.inactive_no_confirm = lambda processor=None, data=None: create_subscription(
status=False, data=data, needs_confirmation=False, subscription_processor=processor
)

return create_subscription
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although quick and helpful, this is a bit hacky. Overloading function definition with quasi-class staticmethod-like lambdas is not idiomatic and is going to be harder to maintain than doing it explicitly as functions/classes.

A quick alternative:

class SubscriptionFactory:
    """Factory for creating subscription data structures with common patterns."""

    @staticmethod
    def make(
        status: bool = False,
        data: Optional[Dict[str, Any]] = None,
        needs_confirmation: bool = True,
        subscription_processor: Optional[Callable] = None,
        channel_suffix: str = ""
    ) -> Dict[str, Any]:
        """Create a subscription dictionary with standard structure."""
        return {
            "status": status,
            "data": data or {"key": f"value{channel_suffix}"},
            "needs_confirmation": needs_confirmation,
            "subscription_processor": subscription_processor,
        }

    @classmethod
    def active(cls, processor: Optional[Callable] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        return cls.make(status=True, data=data, needs_confirmation=True, subscription_processor=processor)

    @classmethod
    def inactive(cls, processor: Optional[Callable] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        return cls.make(status=False, data=data, needs_confirmation=True, subscription_processor=processor)

    @classmethod
    def active_no_confirm(cls, processor: Optional[Callable] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        return cls.make(status=True, data=data, needs_confirmation=False, subscription_processor=processor)

    @classmethod
    def inactive_no_confirm(cls, processor: Optional[Callable] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        return cls.make(status=False, data=data, needs_confirmation=False, subscription_processor=processor)


@pytest.fixture
def subscription_factory() -> SubscriptionFactory:
    """Pytest fixture exposing the subscription factory."""
    return SubscriptionFactory

}

@pytest.fixture
def controller_with_mixed_subscriptions(subscription_factory):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixture doesn't seem to be used anywhere?

Comment on lines +194 to +224
@pytest.mark.parametrize("modifications,expected_status,expected_data,expected_confirmation,expected_processor_is_new", [
# Status only
({'status': True}, True, {'original': 'data'}, True, False),
# Data only
({'data': {'modified': 'data'}}, False, {'modified': 'data'}, True, False),
# Needs confirmation only
({'needs_confirmation': False}, False, {'original': 'data'}, False, False),
# Processor only - we'll test the processor separately since it's a MagicMock
# Multiple parameters
({'status': True, 'data': {'new': 'data'}, 'needs_confirmation': False}, True, {'new': 'data'}, False, False),
])
def test_modify_subscription_parameters(controller_with_test_subscription, modifications, expected_status, expected_data, expected_confirmation, expected_processor_is_new):
# Arrange
original_processor = controller_with_test_subscription._subscriptions['test_channel']['subscription_processor']
if 'subscription_processor' in modifications:
new_processor = MagicMock(spec=SubscriptionProcessor)
modifications['subscription_processor'] = new_processor

# Act
controller_with_test_subscription.modify_subscription('test_channel', **modifications)

# Assert
subscription = controller_with_test_subscription._subscriptions['test_channel']
assert subscription['status'] is expected_status
assert subscription['data'] == expected_data
assert subscription['needs_confirmation'] is expected_confirmation

if 'subscription_processor' in modifications:
assert subscription['subscription_processor'] == modifications['subscription_processor']
else:
assert subscription['subscription_processor'] == original_processor
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking a bit about the readability and maintainability of these tests. It heavily uses the pytest fixtures, which is great, but it does turn it into a little bit of a puzzle to deduce what values are set to.

Take for example second case:

 # Data only
    ({'data': {'modified': 'data'}}, False, {'modified': 'data'}, True, False),

To start with, for this chain of values it is not immediately apparent which variables they map to - I need to spend a while looking back and forth to deduce these which test function parameters they correspond to. I wonder whether dropping @pytest.mark.parametrize for such high number of arguents wouldn't be more readable:

def test_modify_subscription_parameters_status_only(controller_with_test_subscription):
    controller_with_test_subscription.modify_subscription('test_channel', {'data': {'modified': 'data'}})
    ... 
    assert subscription['status'] is True
    ... #etc.

Irrespectively of above, because of nested fixtures figuring out what the expected values should be is also a bit of mental gymnastic, I literally just had to go through this process when reviewing this code:

  • What is the expected status here in the second case?
  • expected_status... Which variable is that? Ah.. it's False
  • Hold up, why is status expected as False?
  • Let me see controller_with_test_subscription
  • Ah it is intialised with subscription_factory.inactive()
  • Let me see subscription_factory
  • [I'm skipping the figuring out the function overloading with lambdas here because I wrote about it above, but it's also a step]
  • Ah inactive is a lambda function, calling create_subscription
  • Let me see create_subscription
  • Ah status is False by default

And similar back and forth needs to happen for many other parameters, even once we figure out they originate from subscription_factory and create_subscription.

Although I'm all for DRY and wrapping repeated code into helper functions, here I think it may be going a step too far. I therefore think, these tests would be cleaner if we made things a little more explicit:

@pytest.fixture
def controller(mock_processor, subscription_factory):
    """Create a SubscriptionController with a predefined test subscription using factory."""
    controller = SubscriptionController(subscription_processor=mock_processor)
    # Add send method since SubscriptionController is a mixin expecting WsClient
    controller.send = MagicMock(return_value=True)
    controller.running = True  # Default to running state
    return controller


@pytest.fixture
def default_subscription():
    return {'status': False, 'data': None, 'needs_confirmation': True, 'subscription_processor': None}


def test_modify_subscription_status(controller, default_subscription):
    # Arrange
    controller._subscriptions['test_channel'] = default_subscription.copy()
    modification = {'status':True}

    # Act
    controller.modify_subscription('test_channel', modification)

    # Assert
    assert controller._subscriptions['test_channel'] == (default_subscription | modification) # or {**default_subscription, **modification}
    
    
def test_modify_subscription_data(controller, default_subscription):
    # Arrange
    controller._subscriptions['test_channel'] = default_subscription.copy()
    modification = {'data':{'modified': 'data'}}

    # Act
    controller.modify_subscription('test_channel', modification)

    # Assert
    assert controller._subscriptions['test_channel'] == (default_subscription | modification)
    
    
def test_modify_subscription_needs_confirmation(controller, default_subscription):
    # Arrange
    controller._subscriptions['test_channel'] = default_subscription.copy()
    modification = {'needs_confirmation':False}

    # Act
    controller.modify_subscription('test_channel', modification)

    # Assert
    assert controller._subscriptions['test_channel'] == (default_subscription | modification)
    
    
def test_modify_subscription_multiple_parameters(controller, default_subscription):
    # Arrange
    controller._subscriptions['test_channel'] = default_subscription.copy()
    modification = {'status': True, 'data': {'new': 'data'}, 'needs_confirmation': False}

    # Act
    controller.modify_subscription('test_channel', modification)

    # Assert
    assert controller._subscriptions['test_channel'] == (default_subscription | modification)

While this introduces a little more repetition, each test becomes much more self-contained and easy to follow - which in general should be a principal philosophy behind tests if I'm not mistaken.

Yes, there still is a bit of DRY and following through to fixtures, but I feel that reading defaults from default_subscription which is directly referenced in each test is cleaner than having to go through 3 fixture redirections before finding defaults and expected values.

I think @pytest.mark.parametrize is great when there are a few parameters but a lot of combinations (like a string-parsing function which takes one parameter, and could accept 10 different forms) - I use it frequently too. But here this kind of long argument list along with the heavy fixture nesting could make debugging errors harder than it needs to be if we made things more explicit accepting a little bit of repetition.

Comment on lines +267 to +270
error_message = str(exc_info.value)
assert nonexistent_channel in error_message
assert 'does not exist' in error_message
assert 'Current subscriptions:' in error_message
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you write it out between three lines like this? It seems a bit verbose to me, but maybe you had some reasoning behind writing it like so? I'd think of a simple:

assert f'Subscription {nonexistent_channel } does not exist. Current subscriptions:' in error_message 

assert 'Current subscriptions:' in error_message


# Tests for _attempt_unsubscribing_repeated method retry logic.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you put a section header with only one test if other such cases don't carry a header? Were there more tests here before?

Comment on lines +275 to +301
# These tests cover the complex retry loop logic that handles WebSocket
# unsubscription attempts with confirmation waiting and failure handling.
@pytest.mark.parametrize("wait_until_results,retries,expected_result,expected_send_calls,expected_wait_calls", [
([True], 5, True, 1, 1), # Success first try
([False, False, True], 3, True, 3, 3), # Success after retries
([False, False], 2, False, 2, 2), # Failure after max retries
])
def test_attempt_unsubscribing_repeated_retry_logic_integration(subscription_controller, monkeypatch, wait_until_results, retries, expected_result, expected_send_calls, expected_wait_calls):
# Arrange
test_channel = 'test_channel'
test_payload = 'unsubscribe_payload'
subscription_controller._subscription_retries = retries

# Mock only external dependencies - test real _send_payload behavior
mock_ws_send = MagicMock(return_value=True)
monkeypatch.setattr(subscription_controller, 'send', mock_ws_send)

mock_wait_until = MagicMock(side_effect=wait_until_results)
monkeypatch.setattr('ibind.base.subscription_controller.wait_until', mock_wait_until)

# Act - Test real retry logic and error handling in _send_payload
result = subscription_controller._attempt_unsubscribing_repeated(test_channel, test_payload)

# Assert
assert result is expected_result
assert mock_ws_send.call_count == expected_send_calls
assert mock_wait_until.call_count == expected_wait_calls
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also bears the @pytest.mark.parametrize readability difficulty I've mentioned earlier.

Then:

  • Why the 'Mock only external dependencies' by mocking send which already is mocked?
  • And why not mock the _send_payload if in other unit tests we mock methods like this? That method just forward return status from send anyway in essence, we're not really testing anything here by keeping it unmocked, are we?

Finally, this test feels a bit like testing whether we mocked things correctly, more than testing the functionality in question. If we mock the wait_until to return mocked values, and do nothing about the self.running, then _attempt_unsubscribing_repeated essentially becomes a for loop where we indirectly call a send mock - and this test just tests whether we can call a mock in a loop, and how many times we called that mock. This is redundant.

It's the wait_until, is_subscription_active and is_running that make this worth testing. If we mock or ignore all these, what is the reasoning behind writing such test?

Comment on lines +319 to +320
mock_ws_send = MagicMock(return_value=subscribe_success)
monkeypatch.setattr(subscription_controller, 'send', mock_ws_send)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send is already a mock, we could just subscription_controller.send.return_value = subscribe_success

Comment on lines +323 to +324
mock_processor = subscription_controller._subscription_processor
mock_processor.make_subscribe_payload = MagicMock(return_value='test_payload')
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly, _subscription_processor is already a mock, from mock_processor. We can just subscription_controller._subscription_processor.make_subscribe_payload .return_value = 'test_payload'

Comment on lines +329 to +330
mock_wait_until = MagicMock(return_value=subscribe_success)
monkeypatch.setattr('ibind.base.subscription_controller.wait_until', mock_wait_until)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgive me if I'm missing something here - once again the pytest nesting and trying to handle all @pytest.mark.parametrize cases in the same test makes things a little harder to reason about - but I don't think this is sufficient to test this properly. Is this test actually passing?

The _attempt_subscribing_repeated is expected to wait until a confirmation which should only happen once a message is received through and parsed by IbkrWsClient._on_message(). Just returning success indicates that the status was modified, but since _on_message was never called the status should still be False.

If it passes, it's most likely only because recreate_subscriptions filters subscriptions for ones that have status=False. Then, you check if after calling recreate_subscriptions the status is equal to that of initial_subscriptions, which will always be False due to aforementioned filter. No actual subscription recreation is tested, it tests that subscriptions that weren't active stay inactive after theoretically having them 'recreated'.

Can you explain what was your logic in covering it in this way? Maybe I'm not seeing something here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants