codamit.dev

Plugin Testing in Convoyr

May 31, 2020

Testing is a one of the most important part of any modern development workflow, that’s why Convoyr is built with a dedicated module for plugin testing.

Promise based testing

Let’s build a simple plugin that fill the response body with an object containing the answer of life.

const addAnswerToLifePlugin: ConvoyrPlugin = {
  handler: {
    handle({ request, next }) {
      return next({ request }).pipe(
        map((response) => ({
          ...response,
          body: { answer: 42 },
        }))
      );
    },
  },
};

All the plugin logic is hold in the handler and should be tested in prior. To do this I create an instance of PluginTester before each test-case.

import { PluginTester, createPluginTester } from '@convoyr/core/testing';

describe('addAnswerToLifePlugin', () => {
  let pluginTester: PluginTester;

  beforeEach(() => {
    pluginTester = createPluginTester({
      plugin: addAnswerToLifePlugin,
    });
  });

  it.todo('should add answer of life in response body');
});

Firstly I mock the final HTTP handler with a fake response using the createHttpHandlerMock function.

Then I execute the plugin using the handleFake function.

it('should add answer of life in response body', async () => {
  /* Arrange */
  const httpHandlerMock = pluginTester.createHttpHandlerMock({
    response: createResponse({ body: null }),
  });

  /* Act */
  const response = await pluginTester
    .handleFake({
      request: createRequest({ url: 'https://answer-of-life.com' }),
      httpHandlerMock,
    })
    .toPromise();

  /* Assert */
  expect(response).toEqual(
    expect.objectContaining({
      { body: { answer: 42 } }
    })
  );
});

Finally I check if my answer to life was added in the response body.

Note that I use the RxJS toPromise function with the async await syntax to simplify this test-case by making it synchronous like.

Observable based testing

Let’s imagine an other plugin that throws if the requested origin is unknown.

export const rejectUnknownOriginsPlugin: ConvoyrPlugin = {
  shouldHandleRequest: not(matchOrigin('https://www.codamit.dev')),
  handler: {
    handle({ request }) {
      return throwError(`🛑 Requesting invalid origin, url: ${request.url}`);
    },
  },
};

Now I need to test if error callback has been called on the observer.

describe('rejectUnknownOriginsPlugin', () => {
  let pluginTester: PluginTester;

  beforeEach(() => {
    pluginTester = createPluginTester({
      plugin: rejectUnknownOriginsPlugin,
    });
  });

  it('should reject unknown origins', () => {
    /* Arrange */
    const httpHandlerMock = pluginTester.createHttpHandlerMock({
      response: createResponse({ body: null }),
    });
    const observer = {
      next: jest.fn(),
      error: jest.fn(),
    };

    /* Act */
    pluginTester
      .handleFake({
        request: createRequest({ url: 'https://rejected-origin.com' }),
        httpHandlerMock,
      })
      .subscribe(observer);

    /* Assert */
    expect(httpHandlerMock).not.toHaveBeenCalled();
    expect(observer.next).not.toHaveBeenCalled();
    expect(observer.error).toHaveBeenCalledTimes(1);
    expect(observer.error).toHaveBeenCalledWith(
      `🛑 Requesting invalid origin, url: https://rejected-origin.com`
    );
  });
});

I also check that the final HTTP handler has not been called since this plugin rejects the request if the origin doesn’t match.

Marbles testing

For more complex use-cases were the plugin emits more than one notification, it could be better to do marbles testing. Take this example which retries failed requests.

const MAX_RETRY_ATTEMPTS = 3;
export const retryPlugin: ConvoyrPlugin = {
  handler: {
    handle({ request, next }) {
      return next.handle({ request }).pipe(
        retryWhen((attempts) =>
          attempts.pipe(
            switchMap((error, i) => {
              const retryAttempt = i + 1;

              /* Stop retrying if max retry attempts reached */
              if (retryAttempt > MAX_RETRY_ATTEMPTS) {
                return throwError(error);
              }

              /* Otherwise retry after 1s, 2s, 3s, etc... */
              return timer(retryAttempt * 1000);
            })
          )
        )
      );
    },
  },
};

Note that I set the frameTimeFactor below to make each marble symbol representing 1 second to match the plugin time-factor.

it(
  'should retry failed request 3 times with an increased delay',
  marbles((m) => {
    /* Arrange */
    TestScheduler['frameTimeFactor'] = 1000;
    const response = createResponse({
      status: 500,
      statusText: 'Internal Server Error',
    });
    const httpHandlerMock = pluginTester.createHttpHandlerMock({
      response: m.cold('#', undefined, response),
    });

    /* Act */
    const response$ = pluginTester.handleFake({
      request: createRequest({ url: 'https://origin.com' }),
      httpHandlerMock,
    });

    /* Assert
                                   👇 Throws after 3 retries in a total of 6s */
    const expected$ = m.cold('------#', undefined, response);
    m.expect(response$).toBeObservable(expected$);
    m.expect(httpHandlerMock()).toHaveSubscriptions([
      '(^!)' /*       Initial call at 0ms */,
      '-(^!)' /*      1st retry at 1000ms */,
      '---(^!)' /*    2nd retry at 3000ms (1st retry delay 1000ms + 2000ms) */,
      '------(^!)' /* 3rd retry at 6000ms (2nd retry delay 3000ms + 3000ms) */,
    ]);
  })
);

Marbles makes complex asynchronous testing possible. But as you can see it’s not the simplest solution, that’s why I use it only if the two previous approaches are ineffective for my test-case.

To recapitulate

There’s three ways to test a plugin :

  • the async await approach which should be the first choice since it looks like completely synchronous,
  • the spy observer approach which is useful for checking for next, error or complete notification.
  • the marbles approach which is recommended if the plugin emits more than one notification with complex asynchronous logic.

I'm Edouard Bozon, I play almost everyday with web technologies. I focus my work on building better JavaScript apps and contributing to open source. I'm freelancer, currently working on awesome projects.