Testing Guards, Interceptors, Pipes, and Decorators in NestJS
Guards, interceptors, pipes, and decorators are NestJS's primary extension points. They're also easy to skip testing because they feel secondary — the "real" code is in services. That's wrong. A broken auth guard means unauthenticated access to every protected route. A broken validation pipe means garbage data reaching your database. These components deserve thorough test coverage.
Here's how to test each one correctly.
Testing Guards
Guards implement CanActivate and return true (allow) or false / throw (deny). They're straightforward to unit test because the interface is simple.
Consider a JWT auth guard:
// auth/jwt-auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (info instanceof TokenExpiredError) {
throw new UnauthorizedException('Token has expired');
}
if (info instanceof JsonWebTokenError) {
throw new UnauthorizedException('Invalid token');
}
if (err || !user) {
throw new UnauthorizedException();
}
return user;
}
}Test the handleRequest method directly — it's plain TypeScript, no HTTP context needed:
// auth/jwt-auth.guard.spec.ts
import { JwtAuthGuard } from './jwt-auth.guard';
import { UnauthorizedException } from '@nestjs/common';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
beforeEach(() => {
guard = new JwtAuthGuard();
});
it('returns user when valid', () => {
const user = { id: 1, email: 'test@example.com' };
expect(guard.handleRequest(null, user, null)).toBe(user);
});
it('throws UnauthorizedException with expired token message', () => {
expect(() => guard.handleRequest(null, null, new TokenExpiredError('expired', new Date())))
.toThrow(new UnauthorizedException('Token has expired'));
});
it('throws UnauthorizedException with invalid token message', () => {
expect(() => guard.handleRequest(null, null, new JsonWebTokenError('invalid')))
.toThrow(new UnauthorizedException('Invalid token'));
});
it('throws UnauthorizedException when no user', () => {
expect(() => guard.handleRequest(null, null, null))
.toThrow(UnauthorizedException);
});
it('rethrows errors passed as first argument', () => {
const error = new UnauthorizedException('custom error');
expect(() => guard.handleRequest(error, null, null))
.toThrow(UnauthorizedException);
});
});For a custom role-based guard that uses Reflector:
// auth/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; // no roles required = public route
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}Test it by mocking the ExecutionContext and Reflector:
// auth/roles.guard.spec.ts
import { RolesGuard } from './roles.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';
function createMockContext(user: any): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as unknown as ExecutionContext;
}
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 required', () => {
reflector.getAllAndOverride.mockReturnValue(undefined);
const ctx = createMockContext({ roles: [] });
expect(guard.canActivate(ctx)).toBe(true);
});
it('allows access when user has required role', () => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
const ctx = createMockContext({ roles: [Role.Admin, Role.User] });
expect(guard.canActivate(ctx)).toBe(true);
});
it('denies access when user lacks required role', () => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
const ctx = createMockContext({ roles: [Role.User] });
expect(guard.canActivate(ctx)).toBe(false);
});
it('denies access when user has no roles', () => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
const ctx = createMockContext({ roles: [] });
expect(guard.canActivate(ctx)).toBe(false);
});
it('denies access when user is undefined', () => {
reflector.getAllAndOverride.mockReturnValue([Role.Admin]);
const ctx = createMockContext(undefined);
expect(guard.canActivate(ctx)).toBe(false);
});
});The createMockContext helper builds a minimal ExecutionContext without the full Nest machinery. It's the pattern you'll use in most guard tests.
Testing Interceptors
Interceptors implement NestInterceptor with an intercept(context, next) method. The next argument is an Observable-based call handler. Testing interceptors requires working with RxJS:
// logging/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 = request.method;
const url = request.url;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
this.logger.log(`${method} ${url} - ${duration}ms`);
}),
);
}
}// logging/logging.interceptor.spec.ts
import { LoggingInterceptor } from './logging.interceptor';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
describe('LoggingInterceptor', () => {
let interceptor: LoggingInterceptor;
let mockContext: Partial<ExecutionContext>;
let mockCallHandler: Partial<CallHandler>;
beforeEach(() => {
interceptor = new LoggingInterceptor();
mockContext = {
switchToHttp: () => ({
getRequest: () => ({ method: 'GET', url: '/users' }),
getResponse: jest.fn(),
getNext: jest.fn(),
}),
} as any;
mockCallHandler = {
handle: jest.fn().mockReturnValue(of({ data: 'response' })),
};
});
it('passes through the response unchanged', (done) => {
interceptor
.intercept(mockContext as ExecutionContext, mockCallHandler as CallHandler)
.subscribe((value) => {
expect(value).toEqual({ data: 'response' });
done();
});
});
it('calls next.handle()', () => {
interceptor.intercept(mockContext as ExecutionContext, mockCallHandler as CallHandler).subscribe();
expect(mockCallHandler.handle).toHaveBeenCalledTimes(1);
});
});For an interceptor that transforms responses (like wrapping in { data: ... }):
// transform/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 }> {
intercept(context: ExecutionContext, next: CallHandler): Observable<{ data: T }> {
return next.handle().pipe(map((data) => ({ data })));
}
}it('wraps response in data key', (done) => {
const handler: CallHandler = { handle: () => of('raw-value') };
interceptor.intercept(mockContext as ExecutionContext, handler).subscribe((result) => {
expect(result).toEqual({ data: 'raw-value' });
done();
});
});Testing Pipes
Pipes implement PipeTransform<T, R> with a single transform(value, metadata) method. They're the easiest to unit test — pure functions with no context needed:
// pipes/parse-positive-int.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException(`${metadata.data} must be a number`);
}
if (val <= 0) {
throw new BadRequestException(`${metadata.data} must be a positive number`);
}
return val;
}
}// pipes/parse-positive-int.pipe.spec.ts
import { ParsePositiveIntPipe } from './parse-positive-int.pipe';
import { BadRequestException } from '@nestjs/common';
import { ArgumentMetadata } from '@nestjs/common';
describe('ParsePositiveIntPipe', () => {
let pipe: ParsePositiveIntPipe;
const metadata: ArgumentMetadata = { type: 'param', data: 'id', metatype: Number };
beforeEach(() => {
pipe = new ParsePositiveIntPipe();
});
it('transforms valid string to positive integer', () => {
expect(pipe.transform('42', metadata)).toBe(42);
expect(pipe.transform('1', metadata)).toBe(1);
});
it('throws BadRequestException for non-numeric string', () => {
expect(() => pipe.transform('abc', metadata))
.toThrow(new BadRequestException('id must be a number'));
});
it('throws BadRequestException for zero', () => {
expect(() => pipe.transform('0', metadata))
.toThrow(new BadRequestException('id must be a positive number'));
});
it('throws BadRequestException for negative numbers', () => {
expect(() => pipe.transform('-5', metadata))
.toThrow(new BadRequestException('id must be a positive number'));
});
it('handles float strings by truncating', () => {
expect(pipe.transform('3.7', metadata)).toBe(3);
});
});Testing Custom Decorators
Custom decorators come in two forms: parameter decorators that extract request data, and metadata decorators that set reflection metadata.
Parameter decorators (like a @CurrentUser() decorator):
// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);Test the factory function directly — createParamDecorator just wraps it:
// decorators/current-user.decorator.spec.ts
import { ExecutionContext } from '@nestjs/common';
// Extract the factory for testing
function currentUserFactory(data: string | undefined, ctx: ExecutionContext) {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
}
function createMockContext(user: any): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as unknown as ExecutionContext;
}
describe('CurrentUser decorator factory', () => {
const user = { id: 1, email: 'test@example.com', role: 'admin' };
it('returns full user when no data argument', () => {
const ctx = createMockContext(user);
expect(currentUserFactory(undefined, ctx)).toEqual(user);
});
it('returns specific field when data argument provided', () => {
const ctx = createMockContext(user);
expect(currentUserFactory('email', ctx)).toBe('test@example.com');
expect(currentUserFactory('role', ctx)).toBe('admin');
});
it('returns undefined when user is not set', () => {
const ctx = createMockContext(undefined);
expect(currentUserFactory(undefined, ctx)).toBeUndefined();
});
it('returns undefined when requested field does not exist', () => {
const ctx = createMockContext(user);
expect(currentUserFactory('nonExistentField', ctx)).toBeUndefined();
});
});Metadata decorators (like @Roles()):
// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);Test that they set the right metadata using Reflector:
// decorators/roles.decorator.spec.ts
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Roles } from './roles.decorator';
import { Role } from './role.enum';
describe('@Roles decorator', () => {
it('sets roles metadata on the handler', () => {
const reflector = new Reflector();
class TestClass {
@Roles(Role.Admin, Role.Moderator)
testMethod() {}
}
const roles = reflector.get<Role[]>(ROLES_KEY, TestClass.prototype.testMethod);
expect(roles).toEqual([Role.Admin, Role.Moderator]);
});
it('sets empty array when no roles provided', () => {
const reflector = new Reflector();
class TestClass {
@Roles()
testMethod() {}
}
const roles = reflector.get<Role[]>(ROLES_KEY, TestClass.prototype.testMethod);
expect(roles).toEqual([]);
});
});Integration Testing Guards and Interceptors
Unit tests verify logic. Integration tests verify that guards and interceptors are wired to the right routes. Use the TestingModule with a real HTTP layer:
describe('RolesGuard integration', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('returns 403 when user lacks required role', async () => {
// Get token for a user-role token
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'user@example.com', password: 'password' });
await request(app.getHttpServer())
.delete('/users/1') // admin-only route
.set('Authorization', `Bearer ${loginRes.body.access_token}`)
.expect(403);
});
});The split between unit and integration tests here is deliberate: unit tests cover all the logic branches (what happens with various role combinations), integration tests verify the guard is actually applied to the routes you think it's applied to.
Exception Filters
Don't forget exception filters — they're also testable with a mocked execution context and ArgumentsHost:
it('formats validation error responses correctly', () => {
const filter = new ValidationExceptionFilter();
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockHost = {
switchToHttp: () => ({
getResponse: () => ({ status: mockStatus }),
getRequest: () => ({ url: '/test' }),
}),
} as unknown as ArgumentsHost;
const exception = new BadRequestException(['email must be valid']);
filter.catch(exception, mockHost);
expect(mockStatus).toHaveBeenCalledWith(400);
expect(mockJson).toHaveBeenCalledWith(expect.objectContaining({
statusCode: 400,
errors: ['email must be valid'],
}));
});Guards, interceptors, pipes, and decorators are infrastructure. When they break, everything breaks. Once they're well-tested locally, HelpMeTest can continuously monitor your deployed API to ensure the full stack — including all your middleware — behaves correctly in production, catching environmental differences that local tests miss.