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

refactor(server): simplify shared metrics tests #1549

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

sbruens
Copy link
Contributor

@sbruens sbruens commented May 16, 2024

Some cleanup I wanted to separate from another PR I'm working on where I'm editing these tests. I wanted to understand the test cases better to make edits safer.

This refactor reduces duplication, separates each distinct test scenario into its own test case, and clearly separates the arrange, act, and assert blocks of each test.

@sbruens sbruens marked this pull request as ready for review May 16, 2024 21:23
@sbruens sbruens requested a review from fortuna as a code owner May 16, 2024 21:23

await clock.runCallbacks();

expect(metricsCollector.collectServerUsageMetrics).toHaveBeenCalledOnceWith({
Copy link
Collaborator

Choose a reason for hiding this comment

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

Prefer a fake implementation (as we had before) than interaction verification: go/choose-test-double

go/choose-test-double#prefer-fakes

Copy link
Contributor Author

Choose a reason for hiding this comment

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

While the "prefer fakes" advice is generally sound, I think it's not the best fit here. The current "fake" is more like a hand-rolled mock used to check interactions, as seen with the added public collectedServerUsageReport and collectedFeatureMetricsReport properties. Since the metrics collector functions are all state-changing (go/mocking#interaction-state-changes), a proper interaction verification would be clearer than a mock pretending to be a fake.

This isn't a blocker so I'll revert for this PR, but it is important to pick the right double for testing. Given how the MetricsCollectorClient interface is currently designed, a fake might not be ideal.

UsageMetrics,
} from './shared_metrics';

describe('OutlineSharedMetricsPublisher', () => {
let clock: ManualClock;
Copy link
Collaborator

Choose a reason for hiding this comment

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

The shared state prevents tests from running in parallel. Is this helping much?

I'd much rather see a set up call before each test, since that is more readable and makes the dependency clear. The hidden dependency hurts readability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using beforeEach ensures a fresh state for each test case, as it's called before each test. I admit that it would be nice to have something with systematic guarantees like go/cleanstate so we don't depend on devs not introducing bugs in beforeEach and afterEach, but that's for another PR.

A setup helper method would have the same end result as beforeEach, but would mean we lose the ability to leverage Jasmine's describe() hierarchy to perform setup at the appropriate suite level. It also means we add bloat to test methods that is unnecessary, where we could simply leverage beforeEach to setup and define implicit defaults, as suggested by go/unit-testing-practices&polyglot=typescript#setUp.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

PS: On the cleanstate alternative, we could do something like assigning properties to each test case's this object:

interface ThisContext {
  ...
  publisher: SharedMetricsPublisher;
}

describe('OutlineSharedMetricsPublisher', function (this: ThisContext) {
  beforeEach(function (this: ThisContext) {
    ...
    this.publisher = new OutlineSharedMetricsPublisher(...);
  });

  it('some test....', function (this: ThisContext) {
    expect(this.publisher.isSharingEnabled()).toBeFalse();

    this.publisher.startSharing();

    expect(this.publisher.isSharingEnabled()).toBeTrue();
  });
});

However this rebinding is generally discouraged, and it's more convoluted, so I don't think we should do that. Just wanted to mention it for completeness.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think all that kind of magic makes the tests harder to understand. It's also not clear what is actually needed by the test.

The "Reads from config" is particularly mind-boggling as it has no action in it!

It's clearer if the test is self-contained, with no access to external state.
You can use helper methods if needed, and still use Jasmine's describe functionality. For example:

  describe('Enable/Disable', () => {
    it('Mirrors config', () => {
      const serverConfig = new InMemoryConfig({serverId: 'server-id'});
      const publisher = makeTestPublisher({serverConfig})

      expect(publisher.isSharingEnabled()).toBeFalse();

      publisher.startSharing();
      expect(publisher.isSharingEnabled()).toBeTrue();
      expect(serverConfig.mostRecentWrite.metricsEnabled).toBeTrue();

      publisher.stopSharing();
      expect(publisher.isSharingEnabled()).toBeFalse();
      expect(serverConfig.mostRecentWrite.metricsEnabled).toBeFalse();
    });

Note how makeTestPublisher allows for overriding fields.

It adds two lines, but all the logic is in one place. I don't need to look somewhere else to understand this test. And I don't need to worry about parallel execution.

@sbruens sbruens requested a review from fortuna May 20, 2024 20:08
Copy link
Collaborator

@fortuna fortuna left a comment

Choose a reason for hiding this comment

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

Let me know what you think. If you feel strongly about sticking to building state in beforeEach we could go with that.

UsageMetrics,
} from './shared_metrics';

describe('OutlineSharedMetricsPublisher', () => {
let clock: ManualClock;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think all that kind of magic makes the tests harder to understand. It's also not clear what is actually needed by the test.

The "Reads from config" is particularly mind-boggling as it has no action in it!

It's clearer if the test is self-contained, with no access to external state.
You can use helper methods if needed, and still use Jasmine's describe functionality. For example:

  describe('Enable/Disable', () => {
    it('Mirrors config', () => {
      const serverConfig = new InMemoryConfig({serverId: 'server-id'});
      const publisher = makeTestPublisher({serverConfig})

      expect(publisher.isSharingEnabled()).toBeFalse();

      publisher.startSharing();
      expect(publisher.isSharingEnabled()).toBeTrue();
      expect(serverConfig.mostRecentWrite.metricsEnabled).toBeTrue();

      publisher.stopSharing();
      expect(publisher.isSharingEnabled()).toBeFalse();
      expect(serverConfig.mostRecentWrite.metricsEnabled).toBeFalse();
    });

Note how makeTestPublisher allows for overriding fields.

It adds two lines, but all the logic is in one place. I don't need to look somewhere else to understand this test. And I don't need to worry about parallel execution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants