Testing NestJS Guards, Interceptors, and Pipes

Testing NestJS Guards, Interceptors, and Pipes

Guards, interceptors, pipes, and exception filters are the middleware layer of NestJS. They run before and after your controllers but are often left untested. This guide shows how to unit test each one in isolation using ExecutionContext mocks, and how to test their integration behavior via e2e tests with Supertest.

Key Takeaways

Mock ExecutionContext to test guards and interceptors in isolation. You don't need a running server — create a mock context that returns the request and response objects your guard needs.

Test both the passing case and the blocking case for every guard. A guard test that only verifies access is granted is half a test.

Pipe unit tests should cover transformation, validation success, and validation failure. Each pipe has a single transform method — test it directly without the full HTTP pipeline.

Exception filters need two assertions: the status code and the response body shape. Both are part of your API contract.

Use e2e tests to verify guards are actually applied to routes. Unit tests prove the guard logic works; e2e tests prove the guard is wired to the right endpoints.

Guards, interceptors, pipes, and exception filters are NestJS's cross-cutting concern layer. They handle authentication, authorization, request transformation, response shaping, and error handling. They're also frequently under-tested — developers write unit tests for services and controllers but skip the middleware layer.

This guide shows you how to test each type thoroughly, both in isolation and in integration.

Testing Guards

Guards implement CanActivate and return a boolean (or a Promise/Observable of boolean). They receive an ExecutionContext that provides access to the request.

Auth Guard Unit Test

// auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: process.env.JWT_SECRET,
      });
      request['user'] = payload;
      return true;
    } catch {
      throw new UnauthorizedException('Invalid token');
    }
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

The key to testing guards is mocking ExecutionContext:

// auth.guard.spec.ts
import { Test } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from './auth.guard';

function createMockExecutionContext(request: Partial<Request>): ExecutionContext {
  return {
    switchToHttp: () => ({
      getRequest: () => request,
      getResponse: () => ({}),
    }),
    getHandler: () => ({}),
    getClass: () => ({}),
  } as unknown as ExecutionContext;
}

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let jwtService: jest.Mocked<JwtService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        AuthGuard,
        {
          provide: JwtService,
          useValue: { verifyAsync: jest.fn() },
        },
      ],
    }).compile();

    guard = module.get(AuthGuard);
    jwtService = module.get(JwtService);
  });

  it('allows access with a valid token', async () => {
    const payload = { userId: 1, email: 'user@example.com' };
    jwtService.verifyAsync.mockResolvedValue(payload);

    const request: any = {
      headers: { authorization: 'Bearer valid.jwt.token' },
    };
    const context = createMockExecutionContext(request);

    const result = await guard.canActivate(context);

    expect(result).toBe(true);
    expect(request.user).toEqual(payload);
    expect(jwtService.verifyAsync).toHaveBeenCalledWith('valid.jwt.token', expect.any(Object));
  });

  it('throws UnauthorizedException when no token is provided', async () => {
    const request: any = { headers: {} };
    const context = createMockExecutionContext(request);

    await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
    await expect(guard.canActivate(context)).rejects.toThrow('No token provided');
  });

  it('throws UnauthorizedException when token is invalid', async () => {
    jwtService.verifyAsync.mockRejectedValue(new Error('invalid signature'));

    const request: any = {
      headers: { authorization: 'Bearer invalid.token' },
    };
    const context = createMockExecutionContext(request);

    await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
    await expect(guard.canActivate(context)).rejects.toThrow('Invalid token');
  });
});

Role Guard Unit Test

Role guards often use the Reflector to read metadata set by decorators:

// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { Role } from './role.enum';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user?.roles?.includes(role));
  }
}
// roles.guard.spec.ts
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
import { Role } from './role.enum';

describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: jest.Mocked<Reflector>;

  beforeEach(() => {
    reflector = { getAllAndOverride: jest.fn() } as any;
    guard = new RolesGuard(reflector);
  });

  it('allows access when no roles are required', () => {
    reflector.getAllAndOverride.mockReturnValue(null);
    const context = createMockExecutionContext({ user: { roles: [] } });

    expect(guard.canActivate(context)).toBe(true);
  });

  it('allows access when user has required role', () => {
    reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
    const context = createMockExecutionContext({ user: { roles: [Role.Admin, Role.User] } });

    expect(guard.canActivate(context)).toBe(true);
  });

  it('denies access when user lacks required role', () => {
    reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
    const context = createMockExecutionContext({ user: { roles: [Role.User] } });

    expect(guard.canActivate(context)).toBe(false);
  });

  it('denies access when user is not authenticated', () => {
    reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
    const context = createMockExecutionContext({ user: null });

    expect(guard.canActivate(context)).toBe(false);
  });
});

Testing Interceptors

Interceptors implement NestInterceptor and use RxJS observables. They can modify the request before it reaches the handler or transform the response after.

Logging Interceptor

// logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const start = Date.now();

    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - start;
        this.logger.log(`${method} ${url}${duration}ms`);
      }),
    );
  }
}
// logging.interceptor.spec.ts
import { of } from 'rxjs';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor';

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

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

  it('passes the response through unchanged', (done) => {
    const mockData = { id: 1, name: 'Test' };
    const next: CallHandler = { handle: () => of(mockData) };
    const context: ExecutionContext = {
      switchToHttp: () => ({
        getRequest: () => ({ method: 'GET', url: '/test' }),
      }),
    } as any;

    interceptor.intercept(context, next).subscribe((data) => {
      expect(data).toEqual(mockData);
      done();
    });
  });
});

Transform Interceptor

Interceptors that wrap all responses in a consistent envelope are common:

// transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, map } from 'rxjs';

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T; timestamp: string }> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<{ data: T; timestamp: string }> {
    return next.handle().pipe(
      map((data) => ({
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}
describe('TransformInterceptor', () => {
  it('wraps response in data envelope with timestamp', (done) => {
    const interceptor = new TransformInterceptor();
    const mockData = [{ id: 1 }, { id: 2 }];
    const next: CallHandler = { handle: () => of(mockData) };
    const context = { switchToHttp: () => ({}) } as any;

    interceptor.intercept(context, next).subscribe((result) => {
      expect(result.data).toEqual(mockData);
      expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
      done();
    });
  });
});

Testing Pipes

Pipes have a single method: transform(value, metadata). They're the simplest to test — just call the method directly, no DI container required.

Custom Validation Pipe

// parse-positive-int.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
  transform(value: string): number {
    const parsed = parseInt(value, 10);
    if (isNaN(parsed) || parsed <= 0) {
      throw new BadRequestException(`${value} is not a positive integer`);
    }
    return parsed;
  }
}
// parse-positive-int.pipe.spec.ts
import { BadRequestException } from '@nestjs/common';
import { ParsePositiveIntPipe } from './parse-positive-int.pipe';

describe('ParsePositiveIntPipe', () => {
  let pipe: ParsePositiveIntPipe;

  beforeEach(() => {
    pipe = new ParsePositiveIntPipe();
  });

  it('transforms a valid positive integer string', () => {
    expect(pipe.transform('42')).toBe(42);
    expect(pipe.transform('1')).toBe(1);
    expect(pipe.transform('1000000')).toBe(1000000);
  });

  it('throws BadRequestException for zero', () => {
    expect(() => pipe.transform('0')).toThrow(BadRequestException);
    expect(() => pipe.transform('0')).toThrow('0 is not a positive integer');
  });

  it('throws BadRequestException for negative numbers', () => {
    expect(() => pipe.transform('-5')).toThrow(BadRequestException);
  });

  it('throws BadRequestException for non-numeric strings', () => {
    expect(() => pipe.transform('abc')).toThrow(BadRequestException);
    expect(() => pipe.transform('')).toThrow(BadRequestException);
  });
});

For pipes that use the ArgumentMetadata second parameter:

it('uses metatype information', () => {
  const metadata: ArgumentMetadata = {
    type: 'param',
    metatype: Number,
    data: 'id',
  };
  expect(pipe.transform('42', metadata)).toBe(42);
});

Testing Exception Filters

Exception filters catch specific exceptions and format the HTTP response. Test them by invoking catch() directly with a mock ArgumentsHost:

// http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Response, Request } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();
    const exceptionResponse = exception.getResponse();

    response.status(status).json({
      statusCode: status,
      message: typeof exceptionResponse === 'string'
        ? exceptionResponse
        : (exceptionResponse as any).message,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}
// http-exception.filter.spec.ts
import { HttpException, HttpStatus } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';

describe('HttpExceptionFilter', () => {
  let filter: HttpExceptionFilter;
  let mockResponse: any;
  let mockHost: any;

  beforeEach(() => {
    filter = new HttpExceptionFilter();

    mockResponse = {
      status: jest.fn().mockReturnThis(),
      json: jest.fn(),
    };

    mockHost = {
      switchToHttp: () => ({
        getResponse: () => mockResponse,
        getRequest: () => ({ url: '/test' }),
      }),
    };
  });

  it('formats 404 response correctly', () => {
    const exception = new HttpException('Not Found', HttpStatus.NOT_FOUND);

    filter.catch(exception, mockHost);

    expect(mockResponse.status).toHaveBeenCalledWith(404);
    expect(mockResponse.json).toHaveBeenCalledWith(
      expect.objectContaining({
        statusCode: 404,
        message: 'Not Found',
        path: '/test',
      }),
    );
  });

  it('includes timestamp in response', () => {
    const exception = new HttpException('Bad Request', HttpStatus.BAD_REQUEST);

    filter.catch(exception, mockHost);

    const jsonCall = mockResponse.json.mock.calls[0][0];
    expect(jsonCall.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });

  it('handles object exception responses', () => {
    const exception = new HttpException(
      { message: ['field is required', 'field must be a string'], error: 'Bad Request' },
      HttpStatus.BAD_REQUEST,
    );

    filter.catch(exception, mockHost);

    const jsonCall = mockResponse.json.mock.calls[0][0];
    expect(Array.isArray(jsonCall.message)).toBe(true);
  });
});

E2e Tests: Verifying Guards Are Applied to Routes

Unit tests prove the guard logic is correct. E2e tests prove the guard is actually applied to the right routes. Both matter — a guard with correct logic that's not registered on the right routes does nothing.

describe('Protected routes (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalGuards(app.get(AuthGuard));
    await app.init();
  });

  afterAll(() => app.close());

  it('GET /profile — rejects unauthenticated requests with 401', () => {
    return request(app.getHttpServer())
      .get('/profile')
      .expect(401);
  });

  it('GET /public — allows unauthenticated access', () => {
    return request(app.getHttpServer())
      .get('/public')
      .expect(200);
  });

  it('GET /admin — rejects non-admin users with 403', async () => {
    const loginRes = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: 'user@example.com', password: 'password' });

    return request(app.getHttpServer())
      .get('/admin')
      .set('Authorization', `Bearer ${loginRes.body.access_token}`)
      .expect(403);
  });
});

Summary

Component Test type Key technique
Guards Unit Mock ExecutionContext, Reflector
Interceptors Unit Mock CallHandler with of(mockData)
Pipes Unit Call transform() directly
Exception filters Unit Mock ArgumentsHost and response object
All of the above E2e Verify enforcement via HTTP with Supertest

Testing these components in isolation is fast and precise. The e2e layer confirms they're wired correctly. Together, they give you confidence that your cross-cutting concerns work exactly as intended.

Read more

Start now free