Skip to content
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

Extract EngagementManager and cover it with unit tests #82

Merged
merged 26 commits into from
Oct 26, 2023

Conversation

wzieba
Copy link
Collaborator

@wzieba wzieba commented Oct 24, 2023

Description

This PR extracts EngagementManager to a separate unit and adds unit tests for it.

The proposed unit test is based on manual Thread#sleep calls and is time-sensitive. It's due to usage of java.utils.Timer which runs a new thread internally - a thread we cannot control. Still, the unit test is written in a way to not be flaky (usages of org.assertj.core.api.isCloseTo assertions) and brings value. E.g., the current test suite covers fix added in #62 .

How to test

Review and green light on CI should be just fine.

So this method can be accessed by extracted `EngagementManager`
The logic itself is complex and should be unit tested. Also, the production implementation will return long intervals (like 10s). Because `EngagementManager` uses TimerTask we have to use `Thread#sleep` in unit tests. All in all - with default implementation of `updateLatestInterval` the unit tests would last very long, hence this extraction, which will allow to inject custom intervals.
To improve readability in tests.
To validate that the tracked event is correct.
The test will contain many unchecked casts, but they're safe - they'll always be <String, Any>.
As per comment: `inc` will always be 0 because the interval between events is lower than 1s
@codecov
Copy link

codecov bot commented Oct 24, 2023

Codecov Report

Attention: 24 lines in your changes are missing coverage. Please review.

Comparison is base (b2882f5) 32.95% compared to head (98dfeaa) 42.06%.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #82      +/-   ##
==========================================
+ Coverage   32.95%   42.06%   +9.10%     
==========================================
  Files           5        7       +2     
  Lines         355      359       +4     
  Branches       56       56              
==========================================
+ Hits          117      151      +34     
+ Misses        225      194      -31     
- Partials       13       14       +1     
Files Coverage Δ
...ava/com/parsely/parselyandroid/ParselyTracker.java 11.41% <20.00%> (+2.82%) ⬆️
...elyandroid/UpdateEngagementIntervalCalculator.java 14.28% <14.28%> (ø)
.../com/parsely/parselyandroid/EngagementManager.java 69.56% <69.56%> (ø)

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

private static final long OFFSET_MATCHING_BASE_INTERVAL = 35;
private static final double BACKOFF_PROPORTION = 0.3;

long updateLatestInterval(@NonNull final Calendar startTime) {
Copy link
Collaborator Author

@wzieba wzieba Oct 25, 2023

Choose a reason for hiding this comment

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

Unit tests for this class are planned to be added in a separate PR.

@wzieba wzieba marked this pull request as ready for review October 25, 2023 10:02
@wzieba wzieba requested a review from ParaskP7 October 25, 2023 10:02
Copy link
Collaborator

@ParaskP7 ParaskP7 left a comment

Choose a reason for hiding this comment

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

👋 @wzieba !

I have reviewed and tested this PR as per the instructions, everything works as expected, another great testing job well done! 🌟 🧪 🌟

But wow, that is a very convoluted test, I am very sorry you had to go through all this... 🫂


I have left a warning (⚠️). a couple of questions (❓), a few suggestions (💡) and some minor (🔍) comments for you to consider. I am going to NOT going to approve this PR for now, just because of this one warning (⚠️) comment, but other than that everything seems okay to me.


EXTRAS

  1. Suggestion (💡): I have noticed that during you refactor commits some of them wouldn't be able to build, just because it would require this other subsequent commit that you add later on. I would personally merge those 2 commits together and make it easier for the reviewer to understand that in order to (for example) extract a class, one would also need to make some methods more public (package private). 🤔
  2. Suggestion (💡): In this PR, I am also seeing some additional commits that are not part of this PR. Consider rebasing you branch with the main one before open a PR for review. This will make it easier for reviewers to know exactly what need to be reviewed and avoid having them to figure out the out-of-scope commits.

public Map<String, Object> baseEvent;
private boolean started;
private final Timer parentTimer;
private TimerTask waitingTimerTask;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggestion (💡): I suggest making this @Nullable and then checking that during stop():

    public void stop() {
        if (waitingTimerTask != null) {
            waitingTimerTask.cancel();
        }
        started = false;
    }

PS: Actually, I would even recommend for such refactor/test PRs to add any missing nullability annotation wherever possible going forward. 🤷

started = false;
}

public boolean isSameVideo(String url, String urlRef, ParselyVideoMetadata metadata) {
Copy link
Collaborator

@ParaskP7 ParaskP7 Oct 26, 2023

Choose a reason for hiding this comment

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

Suggestion (💡): Consider resolving/suppressing all the warnings related to this method:

  1. Suppress: Unchecked cast: 'java.lang.Object' to 'java.util.Map<java.lang.String,java.lang.Object>'
  2. Resolve: Method invocation 'equals' may produce 'NullPointerException'
  3. Resolve: Unboxing of 'baseMetadata.get("duration")' may produce 'NullPointerException'

FYI: For no.2 there is an easy fix, just flit the equals(...), for example:

BEFORE: baseEvent.get("url").equals(url)
AFTER: url.equals(baseEvent.get("url")

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'd leave this to a different PR - I wanted this PR to be focused on extracting EngagementManager and adding minimal set of changes to make it testable.

}

@Test
fun `when starting manager, then schedule task each interval period`() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question (❓): Please help me understand how start() works on EngagementManager for multiple events. 🙏

FYI: I am asking that because, to my understanding, a new instance of EngagementManager is created with just one event, the baseEvent, and that is the only event needed to be enqueued. I am struggling a bit to understand the logic here and when we can have multiple of the same events queuing. 🤔

PS: This is related to 51a4674 .

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

When we call start on EngagementManager, the java.utils.TimerTask is scheduled inside java.utils.Timer. The next execution of the TimerTask will schedule a subsequent task.

So this test checks if subsequent tasks are executed and if they have correct parameters like e.g. updated total timestamp. Does this answer your questions @ParaskP7 ?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmmm, yes, I understand how the TimerTask will schedule a subsequent task. I just don't understand when multiple events, mostly in production, could be scheduled, can you help me with that? 🙏

        engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator);
        engagementManager.start();

I see the above happening on production, which on (for example) startEngagement(...) creates a new instance of EngagementManager with a specific single event. How is this manager is able to handle multiple events, when does this happens, this is what I don't understand @wzieba . 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

How is this manager is able to handle multiple events, when does this happens, this is what I don't understand @wzieba . 🤔

When you say "multiple events" do you mean "multiple engagement sessions"? If yes, then the SDK cannot handle it.

Circling back to the original question:

I am asking that because, to my understanding, a new instance of EngagementManager is created with just one event, the baseEvent, and that is the only event needed to be enqueued.

Yes, the EngegementManager is created with just one event, the baseEvent. But no - it's not the only event needed to be enqueued. After the initial baseEvent is enqueued, enqueuing of the next event is scheduled.

When interval time passes, the SDK, based on baseEvent, enqueues a new event with modifications done in doEnqueue. It modifies, e.g. ts, inc or tt parameters.

Copy link
Collaborator

Choose a reason for hiding this comment

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

When you say "multiple events" do you mean "multiple engagement sessions"? If yes, then the SDK cannot handle it.

Good to know! 💯

Yes, the EngegementManager is created with just one event, the baseEvent. But no - it's not the only event needed to be enqueued. After the initial baseEvent is enqueued, enqueuing of the next event is scheduled.

👍

When interval time passes, the SDK, based on baseEvent, enqueues a new event with modifications done in doEnqueue. It modifies, e.g. ts, inc or tt parameters.

That explains it, and apologies for only knowing the basics of the SDK, trying to figure out the rest during reviews... 😊

So, when startEngagement(...) gets triggered, it is always triggered with a baseEvent, and then, until stopEngagement(...) is called, the same baseEvent will be enqueued multiple time, based on the interval, with only some of its parameters being modified. If so, me repeating correctly what you already said, I then think I got the complete picture now, thanks so much for explaining. 🙇

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the same baseEvent will be enqueued multiple time, based on the interval, with only some of its parameters being modified

Yes, exactly this 🙌 !

withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>,
withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>,
): MapAssert<String, Any> {
return containsEntry("action", "heartbeat")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Minor (🔍): Consider moving the .hasEntrySatisfying("data") check above the .containsEntry("inc", 0L) check to make it easier to follow the same logical order found within the EngagementManager.doEnqueue(...) method, that is, without having to jump up and down to verify test correctness.

        return containsEntry("action", "heartbeat")
            // Incremental will be always 0 because the interval is lower than 1s
            .hasEntrySatisfying("data") { data ->
                data as Map<String, Any>
                assertThat(data).hasEntrySatisfying("ts") { timestamp ->
                    timestamp as Long
                    assertThat(timestamp).withTimestamp()
                }.containsAllEntriesOf(testData.minus("ts"))
            }.containsEntry("inc", 0L)
            .hasEntrySatisfying("tt") { totalTime ->
                totalTime as Long
                assertThat(totalTime).withTotalTime()
            }

import java.util.Calendar;
import java.util.TimeZone;

class UpdateEngagementIntervalCalculator {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggestion (💡): Ah, I forgot to mention this in my review, what about making this a Utils class instead of instantiating it as an object. Maybe even a Kotlin utils class, wdyt? 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In next iterations, I'll inject some Clock to this class to fake the current time. Otherwise, test for this class will be time-sensitive. So I'll have to leave this as a class, not a static one.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice to know, thanks for the heads-up on that. 💯

Copy link
Collaborator

@ParaskP7 ParaskP7 left a comment

Choose a reason for hiding this comment

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

Took it for another spin, everything LGTM now @wzieba , thanks again! 🚀

@wzieba
Copy link
Collaborator Author

wzieba commented Oct 26, 2023

Thank you @ParaskP7 ! 🎉

@wzieba wzieba merged commit 1caf06f into main Oct 26, 2023
3 checks passed
@wzieba
Copy link
Collaborator Author

wzieba commented Oct 27, 2023

@ParaskP7 one question on the initial review:

Suggestion (💡): In this PR, I am also seeing some additional commits that are not part of this PR. Consider rebasing you branch with the main one before open a PR for review.

Which additional commits did you have in mind?

@wzieba wzieba deleted the engagement_manager_tests branch October 27, 2023 07:37
@ParaskP7
Copy link
Collaborator

👋 @wzieba and thanks for the reply! 🙇

Those two below got me a bit confused:

image

🤷

I remembered reviewing those two on this #81 PR, see bottom of this commit list:

image

🤷

Does that help? 🙏

@wzieba
Copy link
Collaborator Author

wzieba commented Oct 27, 2023

I see, thanks! It's interesting though that those commits have different hashes. Normally, we shouldn't see those commits after merging main.

@ParaskP7
Copy link
Collaborator

Yeaaa, not sure what happened there, that's why, by default, I always recommend rebasing branches with latest main branch before opening a PR... 🤷

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.

None yet

2 participants