EricDS
EricDS

Reputation: 486

How to unit test apollo links

I'm currently kinda stuck on how to test some apollo links in my react application, since the official documentation seems to only give advice on how to test your components once they are connected to the provider.

Currently I have 2 links: one to inject an authorization token and one to refresh it once the server returns a response; and what I want to do is either test them separately, or test that the client (which will be build on those links, and a simple HttpLink) performs their logic when conditions are met.

Here are their implementations:

// InjectToken.ts
import { setContext } from '@apollo/client/link/context';

import { getToken } from '../../authentication';

const authenticationLink = setContext(async (_, { headers }) => {

  // Fetches the token from the local storage
  const token = await getToken();

  if (token) {
    return {
      headers: {
        ...headers,
        Authorization: `Bearer ${token}`
      }
    }
  }

  return { headers };
});

export default authenticationLink;
// RefreshToken.ts
import { ApolloLink } from '@apollo/client';

import { refreshToken } from '../../authentication';

const resetTokenLink = new ApolloLink(
  (operation, forward) => forward(operation).map(response => {
    const context = operation.getContext();
    refreshToken(context);
    return response;
  })
);

export default resetTokenLink;

I though about using MockProvider and one of apollo's useQuery or useMutation hooks to fire a "fake" request through the client with a mocked response, but it seems that this mock provider actually mocks the client before resolving the fake data, so it was not on the table for me.

The second option I considered was following this guide, which basically concatenates your link with a custom "assertion" link where you call your expect methods.

While promising, that implementation was not working for me out of the box because the tests were not waiting for the execute call to finish (no assertions managed to execute), so I made some changes to wrap it inside a promise like this:

// mockAssertForLink.ts

// This performs the mock request
async function mockExecuteRequest(link: ApolloLink): Promise<void> {
  return new Promise<void>((resolve): void => {
    const lastLink = new ApolloLink(() => {
      resolve();
      return null;
    })
    execute(ApolloLink.from([link, lastLink]), { query: MockQuery}).subscribe((): void => {
      // Not required for our tests, subscribe merely fires the request
    });
  })
}

// This exposes the assertionCallback after the promise fulfills, and reports the operation object.
export default async function mockAssertForLink(
  link: ApolloLink,
  assertionCallback: (operation: Operation) => void
): Promise<void> {
  return mockExecuteRequest(ApolloLink.from([
    link,
    new ApolloLink((operation, forward) => {
      assertionCallback(operation);
      return forward(operation);
    })
  ]))
}

with this implementating, I'm basically creating two extra links for each link I want to perform a test on:

And my tests were using mockAssertForLink like this:

// InjectToken.test.ts
it('correctly injects authorization header', async () => {
  mocked(getToken).mockResolvedValue(mockToken);
  await mockAssertForLink(authenticationLink, operation => {
    expect(operation.getContext().headers.Authorization).toBe(`Bearer ${mockToken}`)
  });
});

// RefreshToken.ts
it('correctly refreshes the token', async () => {
  await mockAssertForLink(resetTokenLink, () => {
    expect(refreshToken).toHaveBeenCalledTimes(1);
  });
});

This works for the first link, where I'm just injecting a header, but on the second one the assertion always fails, and after a closer look it seems that what I defined inside the map method was never called.

Now, I'm not sure if this is even the correct way to go about this type of tests, as the documentation on the topic is a bit lacking. What I want to know is:

Any help would be greatly appreciated.

Upvotes: 2

Views: 3163

Answers (1)

wantok
wantok

Reputation: 995

I had the same general question and settled on the same answer of resolving a promise at the appropriate time in the link execution harness.

You aren't seeing map get called for a couple reasons:

  • map is called on the result observable, and you aren't returning a result from your terminating link, so map never gets invoked on your link under test.
  • If you did return a result in your terminating link, the current place you're executing the assertion is before map can be called. You need to delay your assertion until after the response processing logic runs.

It is possible to simplify your harness down to adding a single terminating link and then moving your promise resolution logic into the subscribe call. See the example below:

import { ApolloLink, execute, FetchResult, gql, GraphQLRequest, Observable, Operation } from '@apollo/client';

const MockQuery = gql`
  query {
    thing
  }
`;

interface LinkResult<T> {
  operation: Operation;
  result: FetchResult<T>;
}

async function executeLink<T = any, U = any>(
  linkToTest: ApolloLink,
  request: GraphQLRequest = { query: MockQuery },
  responseToReturn: FetchResult<U> = { data: null }
) {
  const linkResult = {} as LinkResult<T>;

  return new Promise<LinkResult<T>>((resolve, reject) => {
    const terminatingLink = new ApolloLink((operation) => {
      linkResult.operation = operation;
      return Observable.of(responseToReturn);
    });

    execute(ApolloLink.from([linkToTest, terminatingLink]), request).subscribe(
      (result) => {
        linkResult.result = result as FetchResult<T>;
      },
      (error) => {
        reject(error);
      },
      () => {
        resolve(linkResult);
      }
    );
  });
}

it('calls refreshToken', async () => {
  const refreshToken = jest.fn();
  const resetTokenLink = new ApolloLink((operation, forward) => {
    operation.variables.test = 'hi';

    return forward(operation)
      .map((response) => {
        refreshToken(operation.getContext());
        return response;
      })
      .map((response) => {
        (response.context ??= {}).addedSomething = true;
        return response;
      });
  });

  const { operation, result } = await executeLink(resetTokenLink);

  expect(refreshToken).toHaveBeenCalled();
  expect(operation.variables.test).toBe('hi');
  expect(result.context?.addedSomething).toBe(true);
});

Instead of inserting assertion logic to run inside a link, I'm just capturing the operation and result values in the promise. You can certainly create other custom links to insert assertions at specific points in the link chain, but it seems nicer to just assert the results at the end.

Upvotes: 6

Related Questions