437 lines
10 KiB
Markdown
437 lines
10 KiB
Markdown
---
|
|
description: How to test backend APIs and services
|
|
---
|
|
|
|
# Testing Workflow
|
|
|
|
Follow these steps to write and run tests for the E-Learning Platform backend using TypeScript and Jest.
|
|
|
|
---
|
|
|
|
## Test Structure
|
|
|
|
```
|
|
tests/
|
|
├── unit/ # Unit tests (services, utilities)
|
|
├── integration/ # Integration tests (API endpoints)
|
|
├── fixtures/ # Test data
|
|
└── setup.js # Test setup
|
|
```
|
|
|
|
---
|
|
|
|
## Step 1: Setup Test Environment
|
|
|
|
Create `tests/setup.ts`:
|
|
|
|
```typescript
|
|
import { PrismaClient } from '@prisma/client';
|
|
import bcrypt from 'bcrypt';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
beforeAll(async () => {
|
|
// Setup test database
|
|
await prisma.$executeRaw`TRUNCATE TABLE "User" CASCADE`;
|
|
|
|
// Create test data
|
|
await createTestUsers();
|
|
await createTestCourses();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
async function createTestUsers() {
|
|
// Create admin, instructor, student
|
|
await prisma.user.createMany({
|
|
data: [
|
|
{
|
|
username: 'admin',
|
|
email: 'admin@test.com',
|
|
password: await bcrypt.hash('password123', 10),
|
|
role_id: 1
|
|
},
|
|
{
|
|
username: 'instructor1',
|
|
email: 'instructor@test.com',
|
|
password: await bcrypt.hash('password123', 10),
|
|
role_id: 2
|
|
},
|
|
{
|
|
username: 'student1',
|
|
email: 'student@test.com',
|
|
password: await bcrypt.hash('password123', 10),
|
|
role_id: 3
|
|
}
|
|
]
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 2: Write Unit Tests
|
|
|
|
Create `tests/unit/course.service.test.ts`:
|
|
|
|
```typescript
|
|
import { courseService } from '../../src/services/course.service';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
describe('Course Service', () => {
|
|
describe('createCourse', () => {
|
|
it('should create course with valid data', async () => {
|
|
const courseData = {
|
|
title: { th: 'ทดสอบ', en: 'Test' },
|
|
description: { th: 'รายละเอียด', en: 'Description' },
|
|
category_id: 1,
|
|
price: 990,
|
|
is_free: false
|
|
};
|
|
|
|
const course = await courseService.create(courseData, 1);
|
|
|
|
expect(course).toHaveProperty('id');
|
|
expect(course.status).toBe('DRAFT');
|
|
expect(course.title.th).toBe('ทดสอบ');
|
|
});
|
|
|
|
it('should throw error with invalid category', async () => {
|
|
const courseData = {
|
|
title: { th: 'ทดสอบ', en: 'Test' },
|
|
category_id: 9999, // Invalid
|
|
price: 990
|
|
};
|
|
|
|
await expect(
|
|
courseService.create(courseData, 1)
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('checkLessonAccess', () => {
|
|
it('should allow access to unlocked lesson', async () => {
|
|
const result = await courseService.checkLessonAccess(1, 1);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
it('should deny access to locked lesson', async () => {
|
|
const result = await courseService.checkLessonAccess(1, 5);
|
|
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason).toBe('incomplete_prerequisites');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: Write Integration Tests
|
|
|
|
Create `tests/integration/courses.test.ts`:
|
|
|
|
```typescript
|
|
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
|
|
describe('Course API', () => {
|
|
let adminToken: string;
|
|
let instructorToken: string;
|
|
let studentToken: string;
|
|
|
|
beforeAll(async () => {
|
|
// Login as different users
|
|
const adminRes = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: 'admin', password: 'password123' });
|
|
adminToken = adminRes.body.token;
|
|
|
|
const instructorRes = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: 'instructor1', password: 'password123' });
|
|
instructorToken = instructorRes.body.token;
|
|
|
|
const studentRes = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: 'student1', password: 'password123' });
|
|
studentToken = studentRes.body.token;
|
|
});
|
|
|
|
describe('POST /api/instructor/courses', () => {
|
|
it('should create course as instructor', async () => {
|
|
const courseData = {
|
|
title: { th: 'คอร์สใหม่', en: 'New Course' },
|
|
description: { th: 'รายละเอียด', en: 'Description' },
|
|
category_id: 1,
|
|
price: 990,
|
|
is_free: false
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses')
|
|
.set('Authorization', `Bearer ${instructorToken}`)
|
|
.send(courseData);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body).toHaveProperty('id');
|
|
expect(response.body.status).toBe('DRAFT');
|
|
});
|
|
|
|
it('should return 403 as student', async () => {
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses')
|
|
.set('Authorization', `Bearer ${studentToken}`)
|
|
.send({});
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it('should return 401 without auth', async () => {
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses')
|
|
.send({});
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/courses', () => {
|
|
it('should list public courses', async () => {
|
|
const response = await request(app)
|
|
.get('/api/courses')
|
|
.query({ page: 1, limit: 10 });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toHaveProperty('data');
|
|
expect(response.body).toHaveProperty('pagination');
|
|
expect(Array.isArray(response.body.data)).toBe(true);
|
|
});
|
|
|
|
it('should filter by category', async () => {
|
|
const response = await request(app)
|
|
.get('/api/courses')
|
|
.query({ category: 1 });
|
|
|
|
expect(response.status).toBe(200);
|
|
response.body.data.forEach((course: any) => {
|
|
expect(course.category_id).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Step 4: Test File Uploads
|
|
|
|
Create `tests/integration/file-upload.test.ts`:
|
|
|
|
```typescript
|
|
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
import path from 'path';
|
|
|
|
describe('File Upload API', () => {
|
|
let instructorToken: string;
|
|
|
|
beforeAll(async () => {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: 'instructor1', password: 'password123' });
|
|
instructorToken = res.body.token;
|
|
});
|
|
|
|
describe('POST /api/instructor/lessons/:lessonId/attachments', () => {
|
|
it('should upload PDF attachment', async () => {
|
|
const filePath = path.join(__dirname, '../fixtures/test.pdf');
|
|
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses/1/lessons/1/attachments')
|
|
.set('Authorization', `Bearer ${instructorToken}`)
|
|
.attach('file', filePath)
|
|
.field('description_th', 'ไฟล์ทดสอบ')
|
|
.field('description_en', 'Test file');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body).toHaveProperty('file_name');
|
|
expect(response.body.mime_type).toBe('application/pdf');
|
|
});
|
|
|
|
it('should reject invalid file type', async () => {
|
|
const filePath = path.join(__dirname, '../fixtures/test.exe');
|
|
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses/1/lessons/1/attachments')
|
|
.set('Authorization', `Bearer ${instructorToken}`)
|
|
.attach('file', filePath);
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(response.body.error.code).toBe('INVALID_FILE_TYPE');
|
|
});
|
|
|
|
it('should reject file too large', async () => {
|
|
// Mock large file
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses/1/lessons/1/attachments')
|
|
.set('Authorization', `Bearer ${instructorToken}`)
|
|
.attach('file', Buffer.alloc(101 * 1024 * 1024)); // 101 MB
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(response.body.error.code).toBe('FILE_TOO_LARGE');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Run Tests
|
|
|
|
// turbo
|
|
Run all tests:
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
// turbo
|
|
Run specific test file:
|
|
```bash
|
|
npm test -- courses.test.js
|
|
```
|
|
|
|
// turbo
|
|
Run with coverage:
|
|
```bash
|
|
npm test -- --coverage
|
|
```
|
|
|
|
// turbo
|
|
Run in watch mode:
|
|
```bash
|
|
npm test -- --watch
|
|
```
|
|
|
|
---
|
|
|
|
## Test Configuration
|
|
|
|
Update `package.json`:
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"test": "jest --runInBand",
|
|
"test:watch": "jest --watch",
|
|
"test:coverage": "jest --coverage"
|
|
},
|
|
"jest": {
|
|
"preset": "ts-jest",
|
|
"testEnvironment": "node",
|
|
"coveragePathIgnorePatterns": ["/node_modules/"],
|
|
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
|
"testMatch": ["**/tests/**/*.test.ts"],
|
|
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Mocking
|
|
|
|
### Mock Prisma
|
|
```javascript
|
|
jest.mock('@prisma/client', () => {
|
|
const mockPrisma = {
|
|
user: {
|
|
findUnique: jest.fn(),
|
|
create: jest.fn()
|
|
},
|
|
course: {
|
|
findMany: jest.fn(),
|
|
create: jest.fn()
|
|
}
|
|
};
|
|
return { PrismaClient: jest.fn(() => mockPrisma) };
|
|
});
|
|
```
|
|
|
|
### Mock S3/MinIO
|
|
```javascript
|
|
jest.mock('../src/services/s3.service', () => ({
|
|
uploadFile: jest.fn().mockResolvedValue({
|
|
url: 'https://s3.example.com/test.pdf',
|
|
key: 'test.pdf'
|
|
}),
|
|
deleteFile: jest.fn().mockResolvedValue(true)
|
|
}));
|
|
```
|
|
|
|
|
|
|
|
---
|
|
|
|
## Test Fixtures
|
|
|
|
Create `tests/fixtures/courses.json`:
|
|
|
|
```json
|
|
{
|
|
"validCourse": {
|
|
"title": { "th": "คอร์สทดสอบ", "en": "Test Course" },
|
|
"description": { "th": "รายละเอียด", "en": "Description" },
|
|
"category_id": 1,
|
|
"price": 990,
|
|
"is_free": false
|
|
},
|
|
"invalidCourse": {
|
|
"title": "Invalid"
|
|
}
|
|
}
|
|
```
|
|
|
|
Use in tests:
|
|
```javascript
|
|
const fixtures = require('../fixtures/courses.json');
|
|
|
|
it('should create course', async () => {
|
|
const response = await request(app)
|
|
.post('/api/instructor/courses')
|
|
.send(fixtures.validCourse);
|
|
|
|
expect(response.status).toBe(201);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
- [ ] Test setup configured
|
|
- [ ] Unit tests written for services
|
|
- [ ] Integration tests written for API endpoints
|
|
- [ ] File upload tests included
|
|
- [ ] Authentication/authorization tested
|
|
- [ ] Error cases tested
|
|
- [ ] All tests passing
|
|
- [ ] Coverage > 80%
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
1. **Test Isolation**: Each test should be independent
|
|
2. **Descriptive Names**: Use clear test descriptions
|
|
3. **AAA Pattern**: Arrange, Act, Assert
|
|
4. **Mock External Services**: Don't call real APIs in tests
|
|
5. **Test Edge Cases**: Not just happy path
|
|
6. **Clean Up**: Reset database state between tests
|
|
7. **Fast Tests**: Keep tests fast (< 5 seconds total)
|