Testing
Testing setup, strategies, and best practices for AsaHome Cloud.
Overview
AsaHome Cloud uses Jest for testing with the following test types:
| Type | Location | Purpose |
|---|---|---|
| Unit Tests | *.spec.ts | Test individual functions/classes |
| Integration Tests | *.spec.ts | Test module interactions |
| E2E Tests | test/*.e2e-spec.ts | Test API endpoints |
Running Tests
All Tests
npm test
Watch Mode
npm run test:watch
Coverage Report
npm run test:cov
E2E Tests
npm run test:e2e
Specific Test File
npm test -- auth.service.spec.ts
Specific Test Name
npm test -- --testNamePattern="should validate password"
Jest Configuration
Configuration in package.json:
{
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s",
"!**/*.module.ts",
"!**/main.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
Unit Testing
Service Test Example
// src/auth/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let service: AuthService;
let usersService: UsersService;
let jwtService: JwtService;
const mockUsersService = {
findByEmail: jest.fn(),
findById: jest.fn(),
};
const mockJwtService = {
sign: jest.fn(() => 'mock-token'),
verify: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<AuthService>(AuthService);
usersService = module.get<UsersService>(UsersService);
jwtService = module.get<JwtService>(JwtService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('validateUser', () => {
it('should return user if credentials are valid', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
const mockUser = {
id: 'user-uuid',
email: 'test@example.com',
password: hashedPassword,
isActive: true,
};
mockUsersService.findByEmail.mockResolvedValue(mockUser);
const result = await service.validateUser('test@example.com', 'password123');
expect(result).toBeDefined();
expect(result.email).toBe('test@example.com');
expect(result).not.toHaveProperty('password');
});
it('should return null if password is invalid', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
const mockUser = {
id: 'user-uuid',
email: 'test@example.com',
password: hashedPassword,
isActive: true,
};
mockUsersService.findByEmail.mockResolvedValue(mockUser);
const result = await service.validateUser('test@example.com', 'wrongpassword');
expect(result).toBeNull();
});
it('should return null if user not found', async () => {
mockUsersService.findByEmail.mockResolvedValue(null);
const result = await service.validateUser('notfound@example.com', 'password');
expect(result).toBeNull();
});
});
describe('login', () => {
it('should return tokens for valid user', async () => {
const mockUser = {
id: 'user-uuid',
email: 'test@example.com',
role: 'user',
};
const result = await service.login(mockUser);
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(jwtService.sign).toHaveBeenCalled();
});
});
});
Controller Test Example
// src/devices/devices.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
describe('DevicesController', () => {
let controller: DevicesController;
let service: DevicesService;
const mockDevicesService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
};
const mockUser = {
id: 'user-uuid',
email: 'test@example.com',
role: 'user',
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DevicesController],
providers: [
{ provide: DevicesService, useValue: mockDevicesService },
],
}).compile();
controller = module.get<DevicesController>(DevicesController);
service = module.get<DevicesService>(DevicesService);
});
describe('findAll', () => {
it('should return array of devices', async () => {
const mockDevices = [
{ id: 'device-1', name: 'Home Hub', isOnline: true },
{ id: 'device-2', name: 'Office Hub', isOnline: false },
];
mockDevicesService.findAll.mockResolvedValue(mockDevices);
const result = await controller.findAll(mockUser);
expect(result).toEqual(mockDevices);
expect(service.findAll).toHaveBeenCalledWith(mockUser.id);
});
});
describe('create', () => {
it('should create and return device', async () => {
const createDto = {
uuid: 'device-uuid',
deviceId: 'home-001',
name: 'Home Hub',
};
const mockDevice = {
id: 'created-id',
...createDto,
isOnline: false,
};
mockDevicesService.create.mockResolvedValue(mockDevice);
const result = await controller.create(createDto, mockUser);
expect(result).toEqual(mockDevice);
expect(service.create).toHaveBeenCalledWith(createDto, mockUser.id);
});
});
});
E2E Testing
Setup
// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}));
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('/api/v1/health (GET)', () => {
it('should return health status', () => {
return request(app.getHttpServer())
.get('/api/v1/health')
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('ok');
expect(res.body.service).toBe('asahome-cloud');
});
});
});
});
Auth E2E Tests
// test/auth.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Auth (e2e)', () => {
let app: INestApplication;
let accessToken: string;
let refreshToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('POST /auth/login', () => {
it('should login with valid credentials', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'password123',
})
.expect(201);
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
expect(response.body.user).toHaveProperty('email', 'test@example.com');
accessToken = response.body.accessToken;
refreshToken = response.body.refreshToken;
});
it('should reject invalid credentials', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'wrongpassword',
})
.expect(401);
});
it('should validate email format', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'invalid-email',
password: 'password123',
})
.expect(400);
expect(response.body.message).toContain('email must be an email');
});
});
describe('POST /auth/refresh', () => {
it('should refresh tokens', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.send({ refreshToken })
.expect(201);
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
});
});
describe('GET /users/me', () => {
it('should return current user with valid token', async () => {
await request(app.getHttpServer())
.get('/api/v1/users/me')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('email');
});
});
it('should reject without token', async () => {
await request(app.getHttpServer())
.get('/api/v1/users/me')
.expect(401);
});
});
});
Testing Utilities
Mock Factories
// test/factories/user.factory.ts
import { User } from '../../src/users/entities/user.entity';
export const createMockUser = (overrides: Partial<User> = {}): User => ({
id: 'test-user-uuid',
email: 'test@example.com',
password: 'hashed-password',
firstName: 'Test',
lastName: 'User',
role: 'user',
isActive: true,
isEmailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});
export const createMockDevice = (overrides = {}) => ({
id: 'test-device-uuid',
uuid: 'device-uuid',
deviceId: 'home-001',
name: 'Test Device',
isOnline: true,
lastSeenAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});
Test Database
For integration tests, use a separate test database:
// test/setup.ts
import { DataSource } from 'typeorm';
export const testDataSource = new DataSource({
type: 'postgres',
host: process.env.TEST_DB_HOST || 'localhost',
port: parseInt(process.env.TEST_DB_PORT || '5433'),
username: 'postgres',
password: 'postgres',
database: 'asahome_cloud_test',
entities: ['src/**/*.entity.ts'],
synchronize: true,
dropSchema: true,
});
Code Coverage
Minimum Coverage Requirements
| Metric | Threshold |
|---|---|
| Statements | 80% |
| Branches | 70% |
| Functions | 80% |
| Lines | 80% |
Coverage Configuration
// package.json
{
"jest": {
"coverageThreshold": {
"global": {
"statements": 80,
"branches": 70,
"functions": 80,
"lines": 80
}
}
}
}
Generate Coverage Report
npm run test:cov
View the HTML report at coverage/lcov-report/index.html.
CI/CD Integration
GitHub Actions
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: asahome_cloud_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm run test:cov
env:
DB_HOST: localhost
DB_PORT: 5432
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Best Practices
- Test in isolation: Mock external dependencies
- Use factories: Create reusable test data builders
- Clean up: Reset state between tests
- Descriptive names: Use clear test descriptions
- AAA pattern: Arrange, Act, Assert
- Test edge cases: Include error scenarios
- Keep tests fast: Mock slow operations