Skip to main content

Testing trong NestJS

Testing là một phần quan trọng của phát triển phần mềm. NestJS cung cấp built-in support cho testing với Jest, cùng với các utilities để test controllers, services, guards, pipes, và toàn bộ ứng dụng. Hiểu cách viết test hiệu quả là chìa khóa để xây dựng reliable applications.

Testing Levels

1. Unit Tests

Kiểm tra một service, controller, hoặc function một cách isolated:

// users.service.ts
@Injectable()
export class UsersService {
constructor(private usersRepository: UsersRepository) {}

async getUser(id: string) {
const user = await this.usersRepository.findOne(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}

async createUser(data: CreateUserDto) {
const existingUser = await this.usersRepository.findByEmail(data.email);
if (existingUser) {
throw new BadRequestException('User already exists');
}
return this.usersRepository.create(data);
}
}

// users.service.spec.ts
describe('UsersService', () => {
let service: UsersService;
let repository: UsersRepository;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UsersRepository,
useValue: {
findOne: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn(),
},
},
],
}).compile();

service = module.get<UsersService>(UsersService);
repository = module.get<UsersRepository>(UsersRepository);
});

describe('getUser', () => {
it('should return a user when found', async () => {
const user = { id: '1', name: 'John', email: 'john@example.com' };
jest.spyOn(repository, 'findOne').mockResolvedValue(user);

const result = await service.getUser('1');

expect(result).toEqual(user);
expect(repository.findOne).toHaveBeenCalledWith('1');
});

it('should throw NotFoundException when user not found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);

await expect(service.getUser('1')).rejects.toThrow(
NotFoundException,
);
});
});

describe('createUser', () => {
it('should create a new user', async () => {
const createUserDto = {
name: 'John',
email: 'john@example.com',
};
const createdUser = { id: '1', ...createUserDto };

jest.spyOn(repository, 'findByEmail').mockResolvedValue(null);
jest.spyOn(repository, 'create').mockResolvedValue(createdUser);

const result = await service.createUser(createUserDto);

expect(result).toEqual(createdUser);
expect(repository.create).toHaveBeenCalledWith(createUserDto);
});

it('should throw BadRequestException if user already exists', async () => {
const createUserDto = {
name: 'John',
email: 'john@example.com',
};
const existingUser = { id: '1', ...createUserDto };

jest.spyOn(repository, 'findByEmail').mockResolvedValue(existingUser);

await expect(service.createUser(createUserDto)).rejects.toThrow(
BadRequestException,
);
});
});
});

2. Integration Tests

Kiểm tra multiple components hoạt động cùng nhau:

describe('Users Module Integration', () => {
let app: INestApplication;
let usersService: UsersService;

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

app = moduleFixture.createNestApplication();
await app.init();

usersService = moduleFixture.get<UsersService>(UsersService);
});

afterAll(async () => {
await app.close();
});

describe('User creation flow', () => {
it('should create user and retrieve it', async () => {
// Create user
const createUserDto = {
name: 'John',
email: 'john@example.com',
};

const createdUser = await usersService.createUser(createUserDto);

// Retrieve user
const retrievedUser = await usersService.getUser(createdUser.id);

expect(retrievedUser).toEqual(createdUser);
});
});
});

3. E2E Tests (End-to-End)

Kiểm tra toàn bộ ứng dụng qua HTTP interface:

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

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

app = moduleFixture.createNestApplication();
await app.init();
});

afterAll(async () => {
await app.close();
});

describe('POST /users', () => {
it('should create a user', () => {
return request(app.getHttpServer())
.post('/users')
.send({
name: 'John',
email: 'john@example.com',
})
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.name).toBe('John');
});
});

it('should return 400 if email already exists', () => {
return request(app.getHttpServer())
.post('/users')
.send({
name: 'Jane',
email: 'john@example.com', // Same email
})
.expect(400);
});
});

describe('GET /users/:id', () => {
it('should return a user', () => {
return request(app.getHttpServer())
.get('/users/1')
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body).toHaveProperty('name');
});
});

it('should return 404 if user not found', () => {
return request(app.getHttpServer())
.get('/users/999')
.expect(404);
});
});
});

Testing Controllers

// users.controller.ts
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}

@Post()
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.createUser(createUserDto);
}

@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.getUser(id);
}
}

// users.controller.spec.ts
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
createUser: jest.fn(),
getUser: jest.fn(),
},
},
],
}).compile();

controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});

describe('create', () => {
it('should create a user', async () => {
const createUserDto = {
name: 'John',
email: 'john@example.com',
};
const user = { id: '1', ...createUserDto };

jest.spyOn(service, 'createUser').mockResolvedValue(user);

const result = await controller.create(createUserDto);

expect(result).toEqual(user);
expect(service.createUser).toHaveBeenCalledWith(createUserDto);
});
});

describe('findOne', () => {
it('should return a user', async () => {
const user = {
id: '1',
name: 'John',
email: 'john@example.com',
};

jest.spyOn(service, 'getUser').mockResolvedValue(user);

const result = await controller.findOne('1');

expect(result).toEqual(user);
expect(service.getUser).toHaveBeenCalledWith('1');
});
});
});

Testing Guards

// auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);

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

try {
const payload = this.jwtService.verify(token);
request.user = payload;
} catch {
throw new UnauthorizedException('Invalid token');
}

return true;
}

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

// auth.guard.spec.ts
describe('AuthGuard', () => {
let guard: AuthGuard;
let jwtService: JwtService;

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

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

describe('canActivate', () => {
it('should return true if token is valid', () => {
const mockRequest = {
headers: {
authorization: 'Bearer valid_token',
},
};

const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;

const payload = { sub: '1', email: 'user@example.com' };
jest.spyOn(jwtService, 'verify').mockReturnValue(payload);

const result = guard.canActivate(mockContext);

expect(result).toBe(true);
expect(mockRequest.user).toEqual(payload);
});

it('should throw UnauthorizedException if no token provided', () => {
const mockRequest = {
headers: {},
};

const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;

expect(() => guard.canActivate(mockContext)).toThrow(
UnauthorizedException,
);
});

it('should throw UnauthorizedException if token is invalid', () => {
const mockRequest = {
headers: {
authorization: 'Bearer invalid_token',
},
};

const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;

jest.spyOn(jwtService, 'verify').mockImplementation(() => {
throw new Error('Invalid token');
});

expect(() => guard.canActivate(mockContext)).toThrow(
UnauthorizedException,
);
});
});
});

Testing Pipes

// validation.pipe.ts
@Injectable()
export class ValidationPipe implements PipeTransform {
async transform(value: any, metadata: ArgumentMetadata) {
if (metadata.type === 'param') {
const id = parseInt(value);
if (isNaN(id)) {
throw new BadRequestException('Invalid ID');
}
return id;
}
return value;
}
}

// validation.pipe.spec.ts
describe('ValidationPipe', () => {
let pipe: ValidationPipe;

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

describe('transform', () => {
it('should convert string to number', async () => {
const result = await pipe.transform('123', {
type: 'param',
metatype: Number,
data: 'id',
});

expect(result).toBe(123);
expect(typeof result).toBe('number');
});

it('should throw BadRequestException if not a valid number', async () => {
await expect(
pipe.transform('invalid', {
type: 'param',
metatype: Number,
data: 'id',
}),
).rejects.toThrow(BadRequestException);
});
});
});

Testing Interceptors

// logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const startTime = Date.now();

return next.handle().pipe(
tap(() => {
const duration = Date.now() - startTime;
console.log(`${request.method} ${request.path} - ${duration}ms`);
}),
);
}
}

// logging.interceptor.spec.ts
describe('LoggingInterceptor', () => {
let interceptor: LoggingInterceptor;

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

describe('intercept', () => {
it('should log request duration', (done) => {
const mockRequest = {
method: 'GET',
path: '/users',
};

const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;

const mockCallHandler = {
handle: () => of('response').pipe(delay(100)),
} as CallHandler;

const consoleSpy = jest.spyOn(console, 'log');

interceptor.intercept(mockContext, mockCallHandler).subscribe(() => {
expect(consoleSpy).toHaveBeenCalled();
const logOutput = consoleSpy.mock.calls[0][0];
expect(logOutput).toContain('GET /users');
expect(logOutput).toContain('ms');
consoleSpy.mockRestore();
done();
});
});
});
});

Mocking Best Practices

1. Mock Services

// ✅ Đúng - Proper mocking
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;

beforeEach(async () => {
const mockUsersService = {
findOne: jest.fn().mockResolvedValue({
id: '1',
name: 'John',
}),
createUser: jest.fn().mockResolvedValue({
id: '1',
name: 'John',
}),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: mockUsersService,
},
],
}).compile();

controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});

it('should call service method', async () => {
await controller.findOne('1');
expect(service.findOne).toHaveBeenCalledWith('1');
});
});

2. Mock Database

// ✅ Đúng - Mock repository
describe('UsersService', () => {
let service: UsersService;
let repository: Repository<User>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();

service = module.get<UsersService>(UsersService);
repository = module.get<Repository<User>>(getRepositoryToken(User));
});

it('should find a user', async () => {
const user = { id: 1, name: 'John' };
jest.spyOn(repository, 'findOne').mockResolvedValue(user);

const result = await service.findOne(1);

expect(result).toEqual(user);
});
});

3. Mock External APIs

// ✅ Đúng - Mock HTTP calls
describe('EmailService', () => {
let service: EmailService;
let httpService: HttpService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailService,
{
provide: HttpService,
useValue: {
post: jest.fn(),
},
},
],
}).compile();

service = module.get<EmailService>(EmailService);
httpService = module.get<HttpService>(HttpService);
});

it('should send email', async () => {
jest.spyOn(httpService, 'post').mockReturnValue(
of({ data: { success: true } }),
);

const result = await service.sendEmail('user@example.com', 'Hello');

expect(result).toBe(true);
expect(httpService.post).toHaveBeenCalled();
});
});

E2E Testing with Supertest

// app.e2e-spec.ts
describe('App (e2e)', () => {
let app: INestApplication;

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

app = moduleFixture.createNestApplication();
await app.init();
});

afterAll(async () => {
await app.close();
});

describe('GET /health', () => {
it('should return 200', () => {
return request(app.getHttpServer())
.get('/health')
.expect(200)
.expect({ status: 'ok' });
});
});

describe('POST /users', () => {
it('should create a user and return it', () => {
return request(app.getHttpServer())
.post('/users')
.send({
name: 'John Doe',
email: 'john@example.com',
})
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.name).toBe('John Doe');
expect(res.body.email).toBe('john@example.com');
});
});
});

describe('GET /users/:id', () => {
let userId: string;

beforeAll(async () => {
const response = await request(app.getHttpServer())
.post('/users')
.send({ name: 'Jane', email: 'jane@example.com' });
userId = response.body.id;
});

it('should return a user', () => {
return request(app.getHttpServer())
.get(`/users/${userId}`)
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(userId);
expect(res.body.name).toBe('Jane');
});
});
});

describe('Authentication', () => {
let token: string;

beforeAll(async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'user@example.com',
password: 'password123',
});
token = response.body.accessToken;
});

it('should return 401 without token', () => {
return request(app.getHttpServer())
.get('/profile')
.expect(401);
});

it('should return 200 with valid token', () => {
return request(app.getHttpServer())
.get('/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
});
});

Test Database Setup

// test-db.module.ts
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
}),
TypeOrmModule.forFeature([User, Post]),
],
})
export class TestDbModule {}

// app.e2e-spec.ts with database
describe('App with Database (e2e)', () => {
let app: INestApplication;
let usersRepository: Repository<User>;

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

app = moduleFixture.createNestApplication();
await app.init();

usersRepository = moduleFixture.get(getRepositoryToken(User));
});

afterEach(async () => {
await usersRepository.clear();
});

afterAll(async () => {
await app.close();
});

it('should create and retrieve user from database', async () => {
// Create
await request(app.getHttpServer())
.post('/users')
.send({ name: 'John', email: 'john@example.com' })
.expect(201);

// Verify in database
const user = await usersRepository.findOne({
where: { email: 'john@example.com' },
});

expect(user).toBeDefined();
expect(user.name).toBe('John');
});
});

Best Practices

1. Test Structure

// ✅ Đúng - Clear arrange-act-assert pattern
describe('UsersService', () => {
describe('getUser', () => {
it('should return user when found', async () => {
// Arrange
const userId = '1';
const expectedUser = { id: userId, name: 'John' };
jest.spyOn(repository, 'findOne').mockResolvedValue(expectedUser);

// Act
const result = await service.getUser(userId);

// Assert
expect(result).toEqual(expectedUser);
});
});
});

2. Test Isolation

// ✅ Đúng - Each test is independent
describe('CacheService', () => {
let service: CacheService;

beforeEach(() => {
service = new CacheService();
});

it('should set and get value', () => {
service.set('key', 'value');
expect(service.get('key')).toBe('value');
});

it('should return null for non-existent key', () => {
expect(service.get('non-existent')).toBeNull();
});
});

3. Meaningful Test Names

// ✅ Đúng - Descriptive test names
describe('AuthService', () => {
it('should throw UnauthorizedException when credentials are invalid', async () => {
// ...
});

it('should return accessToken and refreshToken on successful login', async () => {
// ...
});

it('should update lastLogin timestamp after successful authentication', async () => {
// ...
});
});

4. Test Coverage

# Run tests with coverage
npm run test -- --coverage

# Expected output
# Statements : 85%
# Branches : 80%
# Functions : 90%
# Lines : 85%

5. Async/Await Handling

// ✅ Đúng - Proper async handling
describe('AsyncService', () => {
it('should handle async operations', async () => {
const result = await service.asyncOperation();
expect(result).toBeDefined();
});

it('should handle promise rejection', async () => {
await expect(service.failingOperation()).rejects.toThrow(Error);
});
});

Complete Testing Example

// users.service.ts
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}

async create(createUserDto: CreateUserDto): Promise<User> {
const existing = await this.usersRepository.findOne({
where: { email: createUserDto.email },
});

if (existing) {
throw new BadRequestException('User already exists');
}

return this.usersRepository.save(createUserDto);
}

async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id } });

if (!user) {
throw new NotFoundException('User not found');
}

return user;
}

async findAll(): Promise<User[]> {
return this.usersRepository.find();
}
}

// users.service.spec.ts
describe('UsersService', () => {
let service: UsersService;
let repository: Repository<User>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();

service = module.get<UsersService>(UsersService);
repository = module.get(getRepositoryToken(User));
});

describe('create', () => {
it('should create a new user', async () => {
const createUserDto = { name: 'John', email: 'john@example.com' };
const user = { id: 1, ...createUserDto };

jest.spyOn(repository, 'findOne').mockResolvedValue(null);
jest.spyOn(repository, 'save').mockResolvedValue(user);

const result = await service.create(createUserDto);

expect(result).toEqual(user);
expect(repository.findOne).toHaveBeenCalledWith({
where: { email: createUserDto.email },
});
expect(repository.save).toHaveBeenCalledWith(createUserDto);
});

it('should throw if user already exists', async () => {
const createUserDto = { name: 'John', email: 'john@example.com' };
const existingUser = { id: 1, ...createUserDto };

jest.spyOn(repository, 'findOne').mockResolvedValue(existingUser);

await expect(service.create(createUserDto)).rejects.toThrow(
BadRequestException,
);
});
});

describe('findOne', () => {
it('should return a user', async () => {
const user = { id: 1, name: 'John', email: 'john@example.com' };

jest.spyOn(repository, 'findOne').mockResolvedValue(user);

const result = await service.findOne(1);

expect(result).toEqual(user);
});

it('should throw if user not found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);

await expect(service.findOne(1)).rejects.toThrow(NotFoundException);
});
});

describe('findAll', () => {
it('should return all users', async () => {
const users = [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' },
];

jest.spyOn(repository, 'find').mockResolvedValue(users);

const result = await service.findAll();

expect(result).toEqual(users);
expect(result.length).toBe(2);
});
});
});

// users.controller.spec.ts
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
create: jest.fn(),
findOne: jest.fn(),
findAll: jest.fn(),
},
},
],
}).compile();

controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});

describe('create', () => {
it('should create a user', async () => {
const createUserDto = { name: 'John', email: 'john@example.com' };
const user = { id: 1, ...createUserDto };

jest.spyOn(service, 'create').mockResolvedValue(user);

const result = await controller.create(createUserDto);

expect(result).toEqual(user);
expect(service.create).toHaveBeenCalledWith(createUserDto);
});
});
});

// users.e2e-spec.ts
describe('Users (e2e)', () => {
let app: INestApplication;

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

app = moduleFixture.createNestApplication();
await app.init();
});

afterAll(async () => {
await app.close();
});

describe('POST /users', () => {
it('should create a user', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'John', email: 'john@example.com' })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.name).toBe('John');
});
});
});

describe('GET /users/:id', () => {
it('should return a user', () => {
return request(app.getHttpServer())
.get('/users/1')
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('id');
});
});
});

describe('GET /users', () => {
it('should return all users', () => {
return request(app.getHttpServer())
.get('/users')
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body)).toBe(true);
});
});
});
});

Kết Luận

Testing là essential cho xây dựng robust applications:

Testing Levels:

  • Unit Tests - Test individual services/controllers
  • Integration Tests - Test multiple components together
  • E2E Tests - Test entire application flow

Best Practices:

  • Mock dependencies properly
  • Use clear test names (Arrange-Act-Assert)
  • Maintain test isolation
  • Aim for high coverage (>80%)
  • Test error cases, not just happy paths

Tools:

  • Jest - Testing framework
  • Supertest - HTTP assertions
  • @nestjs/testing - NestJS testing utilities

Testing là investment vào quality, reliability, và maintainability của code!