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 and expose mockLink helper functions normalizeMockedResponse and requestToKey #12138

Conversation

destin-estrela
Copy link

Overview

normalizeMockedResponse and requestToKey were both useful for creating a custom ApolloLink that's compatible with the existing MockedResponse type. So I moved these methods into their own functions and exported them so they can be reused.

This is exposing more implementation details that will then need to be maintained. So let me know if this is acceptable or not. I can also add detailed documentation if we decide to move forward with this.

More specifically, I implemented a custom link called TestSubscriptionLink that, like the internal (?) MockSubscriptionLink, allows controlling subscriptions in tests. But it's higher fidelity and acts as an inverse of the MockLink. It allows pushing a MockedResponse to specific subscriptions using the same request and variable matching logic of mockLink.ts.

Example In Custom ApolloLink

import { ApolloLink, FetchResult, Observable, Operation } from '@apollo/client';
import {
  MockedResponse,
  normalizeMockedResponse,
  requestToKey,
} from '@apollo/client/testing/core';
import { Observer } from '@apollo/client/utilities';
import { equal } from '@wry/equality';

/**
 * A mock subscription link that allows fine-grain control
 * of subscriptions in tests.
 *
 * Use this for testing advanced subscription
 * behavior where the subscription data changes
 * through the course of the test.
 *
 * Don't use this if your test only needs one single, static subscription value.
 * In this case, the default behavior of the `MockLink` is sufficient.
 */
export class TestSubscriptionLink extends ApolloLink {
  private subscriptions: {
    observer: Observer<FetchResult>;
    operation: Operation;
  }[] = [];

  public request(operation: Operation) {
    return new Observable<FetchResult>(observer => {
      this.subscriptions.push({ observer, operation });

      return () => {
        this.subscriptions = this.subscriptions.filter(
          entry => entry.observer !== observer,
        );
      };
    });
  }

  /**
   * Pushes a subscription update to matching active subscriptions and `useSubscription` hooks.
   * Use this method to simulate receiving data from a backend subscription.
   *
   * The provided `MockedResponse` is matched against active subscriptions,
   * and if a match is found, the subscribers receive the data (or error).
   *
   * Throwns an error if no active subscriptions match the provided `MockedResponse`
   * to catch regressions. Only call this method if you are sure the subscription is mounted
   * with matching variables.
   */
  public pushSubscriptionEvent(mockedResponse: MockedResponse) {
    const normalizedMockedResponse = normalizeMockedResponse(mockedResponse);

    const { request, result, error, delay } = normalizedMockedResponse;

    const matchingSubscriptions = this.subscriptions.filter(({ operation }) => {
      const operationKey = requestToKey(operation, true);
      const requestKey = requestToKey(request, true);

      if (operationKey !== requestKey) {
        return false;
      }

      return equal(operation.variables, request.variables || {});
    });

    if (matchingSubscriptions.length === 0) {
      throw new Error(
        'No active subscriptions found matching the provided mocked response.\n' +
          `with variables: ${JSON.stringify(request.variables || {})}\n\n` +
          'The following subscriptions are currently active and mounted:\n' +
          this.subscriptions
            .map(
              ({ operation }) =>
                `${operation.operationName} with variables: ${JSON.stringify(operation.variables)}`,
            )
            .join('\n\n'),
      );
    }

    for (const { observer } of matchingSubscriptions) {
      setTimeout(() => {
        if (error && observer.error) {
          observer.error(error);
        } else if (observer.next && result) {
          observer.next(
            typeof result === 'function'
              ? result(mockedResponse.request.variables ?? {})
              : result,
          );
        }
      }, delay || 0);
    }
  }
}

PS, please consider supporting something like this officially, there's been a few posts on the web here and there asking about it in the last couple of years :).

`normalizeMockedResponse` and `requestToKey` are useful for
creating custom mock links that are compatible with the existing
`MockedResponse` architecture. So the methods are now both exported functions.
@apollo-cla
Copy link

@destin-estrela: Thank you for submitting a pull request! Before we can merge it, you'll need to sign the Apollo Contributor License Agreement here: https://contribute.apollographql.com/

Copy link

netlify bot commented Nov 18, 2024

👷 Deploy request for apollo-client-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit a8c13ce

Copy link

changeset-bot bot commented Nov 18, 2024

🦋 Changeset detected

Latest commit: a8c13ce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@svc-apollo-docs
Copy link

svc-apollo-docs commented Nov 18, 2024

✅ Docs Preview Ready

No new or changed pages found.

@phryneas
Copy link
Member

phryneas commented Nov 18, 2024

I'll discuss this with the team later today this week (sorry, calendar mixup ^^).

That said, I'll already add a few loose thoughts here - this is not a final decision, but meant to give you some insights - comments are very welcome :)

All that said, I'm slightly negative towards exposing more internals here, but I could easily be swayed. Let's see what our discussion later brings up :)

@kettanaito
Copy link

Would absolutely love to give your use case a try! Please share it in mswjs/msw#2352.

MSW + GraphQL Testing Library is the way to go with testing GraphQL APIs.

@destin-estrela
Copy link
Author

@phryneas @kettanaito Thank you for your insights! So far we've been quite comfortable using the traditional MockLink approach to testing that we originally discovered ~2 years ago from the Apollo client documentation. The variable matching and single-use consumption behavior, while annoying to set up sometimes, have helped our confidence in the data we send to the server. Testing this is one of the most important aspects of our front-end tests.

I'll look into how our team can transition to MSW + GraphQL Testing Library to ergonomically accomplish the same thing moving forward.

@phryneas I'll assume based on your comments that mockLink.ts isn't likely to change too much and will be deprecated over time, so exposing more internal implementation details doesn't make sense at the moment. We can still get away with copying and pasting these helper functions locally for now.

In which case, should I close this PR?

@phryneas
Copy link
Member

As you seem to have a workaround in place for now by copy-pasting this, yes, I think it might make sense to close the PR.

That said, kudos for digging into this and opening a PR - generally, it's very welcome!

@destin-estrela
Copy link
Author

Appreciate the warm welcome! Closing this based on our discussions above.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 20, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants