-
Notifications
You must be signed in to change notification settings - Fork 47
Increase test coverage #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
|
As of last commit we are at 75% |
|
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! |
|
Hey @weklund great initiative 👏
|
Let's try this again :) |
|
Updated subscription controller, will update the oauth tests next |
|
@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.
In addition I wanted to wait on adding through coverage to mixins. Would love your thoughts here! :D |
|
Some actions to take for this PR:
|
|
@Voyz Would love your thoughts :) |
Voyz
left a comment
There was a problem hiding this 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?
| # Default subscription configuration | ||
| DEFAULT_SUBSCRIPTION_RETRIES = 5 | ||
| DEFAULT_SUBSCRIPTION_TIMEOUT = 2.0 | ||
| DEFAULT_OPERATIONAL_LOCK_TIMEOUT = 60 |
There was a problem hiding this comment.
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
| @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 |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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?
| @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 |
There was a problem hiding this comment.
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'sFalse- Hold up, why is
statusexpected asFalse? - 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
inactiveis a lambda function, callingcreate_subscription - Let me see
create_subscription - Ah
statusisFalseby 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.
| 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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
| # 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 |
There was a problem hiding this comment.
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
sendwhich already is mocked? - And why not mock the
_send_payloadif in other unit tests we mock methods like this? That method just forward return status fromsendanyway 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?
| mock_ws_send = MagicMock(return_value=subscribe_success) | ||
| monkeypatch.setattr(subscription_controller, 'send', mock_ws_send) |
There was a problem hiding this comment.
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
| mock_processor = subscription_controller._subscription_processor | ||
| mock_processor.make_subscribe_payload = MagicMock(return_value='test_payload') |
There was a problem hiding this comment.
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'
| mock_wait_until = MagicMock(return_value=subscribe_success) | ||
| monkeypatch.setattr('ibind.base.subscription_controller.wait_until', mock_wait_until) |
There was a problem hiding this comment.
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

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.