Пошук уроків, статей та іншого контенту
Протестуєте login, JWT, refresh tokens і захист маршрутів у сценаріях із валідними та помилковими обліковими даними.
Автентифікація складається з кількох окремих контрактів:
login приймає правильні облікові дані й повертає токени.
login відхиляє неправильний пароль і невідомого користувача.
Access JWT можна використати для захищеного маршруту.
Відсутній, пошкоджений або прострочений access token відхиляється.
Refresh token видає нову пару токенів лише після успішної перевірки.
Помилковий або відкликаний refresh token не дозволяє отримати новий access token.
У тестах не потрібно перевіряти конкретний текст токена — важливі його підпис, термін дії та payload.
Приклади нижче використовують:
Jest;
@nestjs/testing;
supertest;
@nestjs/jwt;
bcrypt.
У прикладах refresh token передається в тілі запиту:
{
"refreshToken": "..."
}Якщо застосунок зберігає refresh token у HttpOnly cookie, у тестах замість тіла потрібно перевіряти cookie.
Нехай сервіс має приблизно такий публічний API:
export interface AuthenticatedUser {
id: string;
email: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface UsersService {
findByEmail(email: string): Promise<{
id: string;
email: string;
passwordHash: string;
} | null>;
findById(id: string): Promise<AuthenticatedUser | null>;
}
export interface AuthServiceContract {
validateUser(
email: string,
password: string,
): Promise<AuthenticatedUser>;
login(user: AuthenticatedUser): Promise<AuthTokens>;
refresh(refreshToken: string): Promise<AuthTokens>;
}Типовий flow має виглядати так:
POST /auth/login
└── перевірка email і пароля
└── створення access token і refresh token
GET /users/me
└── JwtAuthGuard
└── перевірка access token
└── передавання user у request
POST /auth/refresh
└── перевірка refresh token
└── створення нових токенівСервіс не повинен повертати різні помилки для випадків «користувача не знайдено» і «неправильний пароль». Для клієнта обидва випадки мають виглядати як 401 Unauthorized.
AuthServiceUnit-тести ізолюють AuthService від бази даних, реального JWT-секрету та bcrypt. Залежності замінюються mock-об’єктами.
Успішний тест повинен перевірити, що:
користувач знайдений;
пароль перевірений;
створено два токени;
у payload access token передано ідентифікатор користувача;
refresh token має окремий тип або іншу ознаку призначення.
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
describe('AuthService', () => {
let authService: AuthService;
const usersService = {
findByEmail: jest.fn(),
findById: jest.fn(),
};
const jwtService = {
signAsync: jest.fn(),
verifyAsync: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: usersService,
},
{
provide: JwtService,
useValue: jwtService,
},
],
}).compile();
authService = module.get(AuthService);
});
describe('validateUser', () => {
it('повертає користувача для правильного пароля', async () => {
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
email: 'anna@example.com',
passwordHash: 'hashed-password',
});
jest.spyOn(bcrypt, 'compare').mockResolvedValue(true as never);
await expect(
authService.validateUser('anna@example.com', 'correct-password'),
).resolves.toEqual({
id: 'user-1',
email: 'anna@example.com',
});
expect(usersService.findByEmail).toHaveBeenCalledWith(
'anna@example.com',
);
expect(bcrypt.compare).toHaveBeenCalledWith(
'correct-password',
'hashed-password',
);
});
it('викидає 401 для невідомого користувача', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(
authService.validateUser('unknown@example.com', 'password'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(bcrypt.compare).not.toHaveBeenCalled();
});
it('викидає 401 для неправильного пароля', async () => {
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
email: 'anna@example.com',
passwordHash: 'hashed-password',
});
jest.spyOn(bcrypt, 'compare').mockResolvedValue(false as never);
await expect(
authService.validateUser('anna@example.com', 'wrong-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('login', () => {
it('створює access і refresh токени', async () => {
jwtService.signAsync
.mockResolvedValueOnce('access-token')
.mockResolvedValueOnce('refresh-token');
const user = {
id: 'user-1',
email: 'anna@example.com',
};
await expect(authService.login(user)).resolves.toEqual({
accessToken: 'access-token',
refreshToken: 'refresh-token',
});
expect(jwtService.signAsync).toHaveBeenNthCalledWith(
1,
{
sub: 'user-1',
email: 'anna@example.com',
type: 'access',
},
expect.any(Object),
);
expect(jwtService.signAsync).toHaveBeenNthCalledWith(
2,
{
sub: 'user-1',
type: 'refresh',
},
expect.any(Object),
);
});
});
describe('refresh', () => {
it('видає нові токени для дійсного refresh token', async () => {
jwtService.verifyAsync.mockResolvedValue({
sub: 'user-1',
type: 'refresh',
});
usersService.findById.mockResolvedValue({
id: 'user-1',
email: 'anna@example.com',
});
jwtService.signAsync
.mockResolvedValueOnce('new-access-token')
.mockResolvedValueOnce('new-refresh-token');
await expect(
authService.refresh('valid-refresh-token'),
).resolves.toEqual({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
expect(jwtService.verifyAsync).toHaveBeenCalledWith(
'valid-refresh-token',
expect.any(Object),
);
});
it('відхиляє access token, переданий замість refresh token', async () => {
jwtService.verifyAsync.mockResolvedValue({
sub: 'user-1',
type: 'access',
});
await expect(
authService.refresh('access-token'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(jwtService.signAsync).not.toHaveBeenCalled();
});
it('відхиляє пошкоджений або прострочений refresh token', async () => {
jwtService.verifyAsync.mockRejectedValue(new Error('invalid token'));
await expect(
authService.refresh('broken-token'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(usersService.findById).not.toHaveBeenCalled();
});
it('відхиляє refresh token для видаленого користувача', async () => {
jwtService.verifyAsync.mockResolvedValue({
sub: 'deleted-user',
type: 'refresh',
});
usersService.findById.mockResolvedValue(null);
await expect(
authService.refresh('valid-but-orphaned-token'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});У цьому тесті expect.any(Object) навмисно не перевіряє конкретні значення опцій. Тривалість життя токенів і секрети часто відрізняються між середовищами. Якщо ці параметри є частиною контракту, їх можна перевірити точніше:
expect(jwtService.signAsync).toHaveBeenCalledWith(
expect.objectContaining({
sub: 'user-1',
type: 'access',
}),
expect.objectContaining({
expiresIn: '15m',
}),
);JWT містить часові поля, тому значення токена змінюється між запусками тесту. Не слід писати так:
expect(result.accessToken).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
);Краще перевірити, що токен можна верифікувати тим самим JwtService, а його payload відповідає очікуванням:
const payload = await jwtService.verifyAsync(result.accessToken);
expect(payload).toEqual(
expect.objectContaining({
sub: 'user-1',
type: 'access',
}),
);Для unit-тесту JwtService зазвичай mock-ають. Реальну підписку й верифікацію перевіряють в інтеграційному або e2e-тесті.
E2E-тест перевіряє не тільки сервіс, а й повний HTTP-контракт:
HTTP-метод;
URL;
формат тіла;
статус відповіді;
структуру JSON;
поведінку глобальних pipe та exception filters.
Приклад контролера:
import {
Body,
Controller,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(@Body() body: { email: string; password: string }) {
const user = await this.authService.validateUser(
body.email,
body.password,
);
return this.authService.login(user);
}
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
return this.authService.refresh(body.refreshToken);
}
@Post('check')
@UseGuards(JwtAuthGuard)
check(@Req() request: Request) {
return {
user: request.user,
};
}
}E2E-тест можна запускати з тестовою базою даних або з тестовими залежностями, підготовленими в окремому TestingModule:
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Authentication (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();
});
it('логін повертає access і refresh токени', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'correct-password',
})
.expect(201);
expect(response.body).toEqual({
accessToken: expect.any(String),
refreshToken: expect.any(String),
});
expect(response.body.accessToken.length).toBeGreaterThan(20);
expect(response.body.refreshToken.length).toBeGreaterThan(20);
});
it('логін повертає 401 для неправильного пароля', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'wrong-password',
})
.expect(401);
expect(response.body).toEqual(
expect.objectContaining({
statusCode: 401,
}),
);
});
it('логін повертає 401 для невідомого email', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'missing@example.com',
password: 'correct-password',
})
.expect(401);
});
it('refresh повертає нову пару токенів', async () => {
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'correct-password',
})
.expect(201);
const oldRefreshToken = loginResponse.body.refreshToken;
const refreshResponse = await request(app.getHttpServer())
.post('/auth/refresh')
.send({
refreshToken: oldRefreshToken,
})
.expect(201);
expect(refreshResponse.body).toEqual({
accessToken: expect.any(String),
refreshToken: expect.any(String),
});
expect(refreshResponse.body.accessToken).not.toBe(
loginResponse.body.accessToken,
);
});
it('refresh повертає 401 для пошкодженого токена', async () => {
await request(app.getHttpServer())
.post('/auth/refresh')
.send({
refreshToken: 'not-a-jwt',
})
.expect(401);
});
});Статус відповіді для POST у NestJS за замовчуванням — 201. Якщо контролер використовує @HttpCode(200), у тесті потрібно очікувати 200.
Захищений маршрут потрібно перевірити щонайменше в трьох сценаріях:
без заголовка Authorization;
з пошкодженим або простроченим токеном;
із дійсним access token.
describe('protected route', () => {
it('повертає 401 без access token', async () => {
await request(app.getHttpServer())
.post('/auth/check')
.expect(401);
});
it('повертає 401 для пошкодженого access token', async () => {
await request(app.getHttpServer())
.post('/auth/check')
.set('Authorization', 'Bearer broken-token')
.expect(401);
});
it('дозволяє доступ із дійсним access token', async () => {
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'correct-password',
})
.expect(201);
const accessToken = loginResponse.body.accessToken;
const response = await request(app.getHttpServer())
.post('/auth/check')
.set('Authorization', `Bearer ${accessToken}`)
.expect(201);
expect(response.body.user).toEqual(
expect.objectContaining({
id: 'user-1',
email: 'anna@example.com',
}),
);
});
it('не приймає refresh token для захищеного маршруту', async () => {
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'correct-password',
})
.expect(201);
await request(app.getHttpServer())
.post('/auth/check')
.set(
'Authorization',
`Bearer ${loginResponse.body.refreshToken}`,
)
.expect(401);
});
});Останній тест особливо важливий, якщо access і refresh токени підписуються одним секретом. Сам факт валідного підпису не означає, що токен можна використовувати для будь-якої операції. Guard або strategy повинні перевіряти призначення токена.
Якщо застосунок використовує rotation, після кожного успішного refresh старий refresh token стає недійсним. Тоді тест повинен перевіряти повторне використання старого токена:
it('відхиляє повторне використання старого refresh token', async () => {
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'correct-password',
})
.expect(201);
const oldRefreshToken = loginResponse.body.refreshToken;
await request(app.getHttpServer())
.post('/auth/refresh')
.send({
refreshToken: oldRefreshToken,
})
.expect(201);
await request(app.getHttpServer())
.post('/auth/refresh')
.send({
refreshToken: oldRefreshToken,
})
.expect(401);
});Цей тест коректний лише тоді, коли rotation справді реалізовано. Якщо застосунок дозволяє повторне використання refresh token до завершення його терміну дії, очікування потрібно змінити.
Для rotation сервер зазвичай зберігає не сам refresh token, а його хеш або ідентифікатор сесії. Тест має перевіряти саме поведінку системи, а не внутрішній спосіб зберігання.
Клієнтському коду важливо отримувати стабільний формат помилки. Наприклад:
{
"statusCode": 401,
"message": "Unauthorized"
}Тестуйте структуру, але не прив’язуйтеся до зайвих деталей exception filter:
it('повертає стандартизовану помилку для неправильних даних', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'anna@example.com',
password: 'wrong-password',
})
.expect(401);
expect(response.body.statusCode).toBe(401);
expect(response.body.message).toBeDefined();
});Не варто повертати клієнту повідомлення на кшталт User not found або Password is incorrect. Це створює можливість перевіряти наявність email у системі.
Автентифікаційні e2e-тести повинні працювати з передбачуваними даними:
створюйте тестового користувача перед тестами;
використовуйте окрему тестову базу даних;
очищайте дані після завершення набору тестів;
не використовуйте production-секрети;
не залежте від порядку виконання тестів.
Приклад підготовки тестового користувача:
beforeAll(async () => {
await usersRepository.deleteAll();
await usersRepository.create({
id: 'user-1',
email: 'anna@example.com',
passwordHash: await bcrypt.hash('correct-password', 10),
});
});Якщо тести запускаються паралельно, кожен набір повинен мати власні дані або транзакцію. Інакше один тест може видалити користувача, потрібного іншому тесту.
Перевірка терміну дії JWT залежить від поточного часу. Для сценаріїв із простроченим токеном використовуйте фіксований час:
import { JwtService } from '@nestjs/jwt';
describe('JWT expiration', () => {
let jwtService: JwtService;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
jwtService = new JwtService({
secret: 'test-secret',
});
});
afterEach(() => {
jest.useRealTimers();
});
it('відхиляє токен після завершення терміну дії', async () => {
const token = await jwtService.signAsync(
{
sub: 'user-1',
type: 'access',
},
{
expiresIn: '1s',
},
);
jest.setSystemTime(new Date('2026-01-01T12:00:02.000Z'));
await expect(jwtService.verifyAsync(token)).rejects.toThrow();
});
});Фіксований час робить тест стабільним і не змушує його реально чекати завершення терміну дії.
Окрім перевірки автентичності, e2e-тести мають перевіряти базові помилки DTO, якщо в застосунку увімкнено ValidationPipe.
Наприклад, для login:
it('відхиляє login без email', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
password: 'correct-password',
})
.expect(400);
});
it('відхиляє login із некоректним email', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'not-an-email',
password: 'correct-password',
})
.expect(400);
});
it('відхиляє refresh без refresh token', async () => {
await request(app.getHttpServer())
.post('/auth/refresh')
.send({})
.expect(400);
});Ці тести відрізняються від тестів із 401:
400 Bad Request означає некоректну структуру або формат запиту;
401 Unauthorized означає, що облікові дані або токен не пройшли автентифікацію.
JWT змінюється через iat, випадкові дані або різний час генерації. Перевіряйте payload, можливість верифікації та наявність токена.
Якщо в e2e-тесті JwtAuthGuard замінений mock-ом, такий тест перевіряє лише контролер. Він не виявить помилки в:
заголовку Authorization;
Passport strategy;
секреті;
перевірці терміну дії;
розборі payload.
Mock guard доречний у unit-тесті контролера, але хоча б один e2e-набір має використовувати справжній guard.
Позитивний сценарій не виявить помилок у випадках:
невідомого користувача;
неправильного пароля;
порожнього тіла;
пошкодженого токена;
використання refresh token як access token.
Для кожного з них потрібен окремий тест.
Не тестуйте й не реалізовуйте різні публічні повідомлення для невідомого email і неправильного пароля. Обидва сценарії повинні мати однакову зовнішню поведінку.
Тестове середовище повинно мати власні JWT-секрети та окремі дані. Навіть якщо секрет потрапить у логи або репозиторій, це не повинно створити ризик для production.
Якщо access і refresh JWT мають однаковий формат, guard може випадково прийняти refresh token для захищеного маршруту. Додавайте в payload ознаку типу або використовуйте різні секрети й стратегії, а потім закріплюйте цю поведінку тестом.
Тест refresh не повинен використовувати токен, створений іншим тестом. Кожен тест має самостійно підготувати користувача й отримати потрібний токен.
AuthService тестуйте unit-тестами з mock-залежностями.
Перевіряйте login для правильних і неправильних облікових даних.
Не порівнюйте JWT як звичайний рядок.
Перевіряйте payload, підпис, тип токена та термін дії.
E2E-тестами перевіряйте HTTP-контракт login і refresh.
Для захищених маршрутів тестуйте відсутній, пошкоджений, неправильний і дійсний access token.
Не дозволяйте використовувати refresh token як access token.
Якщо реалізовано rotation, окремо перевіряйте повторне використання старого refresh token.
Використовуйте ізольовані тестові дані й окремі секрети.