Search code examples
jestjsnestjs

NestJs request and response interceptor unit testing


I would like to log the incoming requests and outgoing responses for my API. I created a request interceptor and a response interceptor as described here

https://docs.nestjs.com/interceptors

So the request interceptor only logs the request object

@Injectable()
export class RequestInterceptor implements NestInterceptor {
  private readonly logger: Logger = new Logger(RequestInterceptor.name, true);

  public intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const { originalUrl, method, params, query, body } = context.switchToHttp().getRequest();
    
    this.logger.debug({ originalUrl, method, params, query, body }, this.intercept.name);
    
    return next.handle();
  }
}

and the response interceptor waits for the outgoing response and logs the status code and response object later on

@Injectable()
export class ResponseInterceptor implements NestInterceptor {
  private readonly logger: Logger = new Logger(ResponseInterceptor.name, true);

  public intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const { statusCode } = context.switchToHttp().getResponse();

    return next.handle().pipe(
      tap((responseData: any) =>
        this.logger.debug({ statusCode, responseData }, this.intercept.name),
      ),
    );
  }
}

I would like to test them but unfortunately have almost no experience in testing. I tried to start with the request interceptor and came up with this

const executionContext: any = {
  switchToHttp: jest.fn().mockReturnThis(),
  getRequest: jest.fn().mockReturnThis(),
};

const nextCallHander: CallHandler<any> = {
  handle: jest.fn(),
};

describe('RequestInterceptor', () => {
  let interceptor: RequestInterceptor;

  beforeEach(() => {
    interceptor = new RequestInterceptor();
  });

  describe('intercept', () => {
    it('should fetch the request object', (done: any) => {
      const requestInterception: Observable<any> = interceptor.intercept(executionContext, nextCallHander);

      requestInterception.subscribe({
        next: value => {
          // ... ??? ...
        },
        error: error => {
          throw error;
        },
        complete: () => {
          done();
        },
      });
    });
  });
});

I currently don't know what to pass into the next callback but when I try to run the test as it is it says that the requestInterception variable is undefined. So the test fails before reaching the next callback. So the error message I get is

TypeError: Cannot read property 'subscribe' of undefined

I also tried to test the response interceptor and came up with this

const executionContext: any = {
  switchToHttp: jest.fn().mockReturnThis(),
  getResponse: jest.fn().mockReturnThis()
};

const nextCallHander: CallHandler<any> = {
  handle: jest.fn()
};

describe("ResponseInterceptor", () => {
  let interceptor: ResponseInterceptor;

  beforeEach(() => {
    interceptor = new ResponseInterceptor();
  });

  describe("intercept", () => {
    it("should fetch the statuscode and response data", (done: any) => {
      const responseInterception: Observable<any> = interceptor.intercept(
        executionContext,
        nextCallHander
      );

      responseInterception.subscribe({
        next: value => {
          // ...
        },
        error: error => {
          throw error;
        },
        complete: () => {
          done();
        }
      });
    });
  });
});

This time I get an error at the interceptor

TypeError: Cannot read property 'pipe' of undefined

Would some mind helping me to test those two interceptors properly?

Thanks in advance


Solution

  • Testing interceptors can be one of the most challenging parts of testing a NestJS application because of the ExecutionContext and returning the correct value from next.

    Let's start with the ExecutionContext:

    You've got an all right set up with your current context, the important thing is that you have a switchToHttp() method if you are using HTTP (like you are) and that whatever is returned by switchToHttp() has a getResponse() or getRequest() method (or both if both are used). From there, the getRequest() or getResponse() methods should return values that are used from the req and res, such as res.statusCode or req.originalUrl. I like having incoming and outgoing on the same interceptor, so often my context objects will look something like this:

    const context = {
      switchToHttp: jest.fn(() => ({
        getRequest: () => ({
          originalUrl: '/',
          method: 'GET',
          params: undefined,
          query: undefined,
          body: undefined,
        }),
        getResponse: () => ({
          statusCode: 200,
        }),
      })),
      // method I needed recently so I figured I'd add it in
      getType: jest.fn(() => 'http')
    }
    

    This just keeps the context light and easy to work with. Of course you can always replace the values with more complex ones as you need for logging purposes.

    Now for the fun part, the CallHandler object. The CallHandler has a handle() function that returns an observable. At the very least, this means that your next object needs to look something like this:

    const next = {
      handle: () => of()
    }
    

    But that's pretty basic and doesn't help much with logging responses or working with response mapping. To make the handler function more robust we can always do something like

    const next = {
      handle: jest.fn(() => of(myDataObject)),
    }
    

    Now if needed you can override the function via Jest, but in general this is enough. Now your next.handle() will return an Observable and will be pipable via RxJS operators.

    Now for testing the Observable, you're just about right with the subscribe you're working with, which is great! One of the tests can look like this:

    describe('ResponseInterceptor', () => {
      let interceptor: ResponseInterceptor;
      let loggerSpy = jest.spyOn(Logger.prototype, 'debug');
    
      beforeEach(() => {
        interceptor = new ResponseInterceptor();
      });
    
      afterEach(() => {
        loggerSpy.resetMock();
      });
    
      describe('intercept', () => {
        it('should fetch the request object', (done: any) => {
          const responseInterceptor: Observable<any> = interceptor.intercept(executionContext, nextCallHander);
    
          responseInterceptor.subscribe({
            next: value => {
              // expect the logger to have two parameters, the data, and the intercept function name
              expect(loggerSpy).toBeCalledWith({statusCode: 200, responseData: value}, 'intercept');
            },
            error: error => {
              throw error;
            },
            complete: () => {
              // only logging one request
              expect(loggerSpy).toBeCalledTimes(1);
              done();
            },
          });
        });
      });
    });
    

    Where executionContext and callHandler are from the values we set up above.

    A similar idea could be done with the RequestInterceptor, but only logging in the complete portion of the observer (the subscribe callback) as there are no data points returned inherently (though it would still work either way due to how observables work).

    If you would like to see a real-world example (albeit one with a mock creation library), you can check out my code for a logging package I'm working on.