Bài 33.2: Integration tests cho API routes

Bài 33.2: Integration tests cho API routes
Sau khi đã xây dựng và phát triển các API routes (điểm cuối API) cho ứng dụng web của mình, một câu hỏi quan trọng đặt ra là: làm thế nào để chúng ta chắc chắn rằng chúng hoạt động đúng như mong đợi trong mọi tình huống? Unit tests rất tuyệt vời để kiểm tra các hàm, các module riêng lẻ, nhưng chúng không đủ để kiểm tra toàn bộ luồng xử lý từ khi một yêu cầu (request) HTTP được gửi đến server cho đến khi server xử lý yêu cầu đó, tương tác với cơ sở dữ liệu (nếu có) và trả về một phản hồi (response).
Đây chính là lúc Integration tests cho API routes trở nên vô cùng cần thiết.
Integration Tests là gì và tại sao lại cần chúng cho API Routes?
Integration tests (Kiểm thử tích hợp) là loại kiểm thử tập trung vào việc xác minh sự tương tác giữa các module hoặc các thành phần khác nhau của một ứng dụng. Đối với API routes, điều này có nghĩa là chúng ta sẽ kiểm tra sự phối hợp giữa:
- Lớp Routing: Đảm bảo yêu cầu được gửi đến đúng handler.
- Lớp Controller/Handler: Logic xử lý chính của yêu cầu.
- Lớp Service/Business Logic: Các quy tắc nghiệp vụ phức tạp.
- Lớp Data Access (DAO/Repository): Tương tác với cơ sở dữ liệu.
- Middleware: Các logic xử lý trung gian (xác thực, ghi log, xử lý lỗi...).
Tại sao lại cần Integration tests cho API routes?
- Kiểm tra toàn diện luồng xử lý: Nó bắt đầu từ một yêu cầu HTTP thực tế và đi qua toàn bộ stack của server, giống hệt cách người dùng hoặc ứng dụng client tương tác với API.
- Phát hiện lỗi khó lường: Unit tests có thể không phát hiện được các vấn đề xảy ra khi các thành phần tích hợp với nhau, ví dụ:
- Kết nối cơ sở dữ liệu bị lỗi hoặc sai cấu hình.
- Lỗi serializing/deserializing dữ liệu khi gửi/nhận (ví dụ: JSON).
- Lỗi trong logic middleware.
- Sai sót trong định tuyến request.
- Race conditions hoặc vấn đề đồng bộ hóa.
- Đảm bảo hợp đồng API (API Contract): Integration tests giúp xác minh rằng API trả về đúng mã trạng thái (status code), đúng cấu trúc dữ liệu và đúng dữ liệu theo như thiết kế.
- Tăng độ tin cậy: Khi có một bộ Integration tests mạnh mẽ, bạn có thể tự tin hơn khi triển khai các thay đổi mới, biết rằng bạn sẽ sớm phát hiện ra nếu có gì đó bị hỏng trong luồng xử lý chính.
Nói cách khác, Unit tests xác minh từng viên gạch là tốt, còn Integration tests xác minh bức tường được xây từ những viên gạch đó có vững chắc hay không.
Chuẩn bị môi trường
Để viết Integration tests cho API routes trong môi trường Node.js (ví dụ: với Express.js), bạn thường sẽ cần:
- Một Framework kiểm thử: Phổ biến nhất là Jest hoặc Mocha.
- Một thư viện để thực hiện HTTP requests đến ứng dụng:
supertest
là lựa chọn rất phổ biến vì nó tích hợp tốt với các framework server Node.js (như Express), cho phép bạn gửi request đến ứng dụng của mình mà không cần phải chạy server trên một cổng mạng thực tế. Nó mô phỏng request/response. Một lựa chọn khác làaxios
hoặcnode-fetch
nếu bạn muốn test một server đang chạy trên cổng cụ thể. - Thiết lập cơ sở dữ liệu (nếu API tương tác với DB): Đối với Integration tests, bạn thường sẽ test cả lớp tương tác database. Do đó, bạn cần một cơ sở dữ liệu để test. Tốt nhất là sử dụng một cơ sở dữ liệu tách biệt hoặc một phiên bản trong bộ nhớ (in-memory) cho môi trường test để tránh làm bẩn dữ liệu thật và đảm bảo tính độc lập của các test case.
Chúng ta sẽ tập trung vào việc sử dụng Jest và Supertest vì sự tiện lợi của chúng.
Cài đặt các gói cần thiết:
npm install --save-dev jest supertest
Viết Integration Tests cho API Routes
Cấu trúc cơ bản của một Integration test sử dụng Jest và Supertest sẽ như sau:
const request = require('supertest');
const app = require('../src/app'); // Giả sử file app.js export instance của ứng dụng Express
describe('API Routes Integration Tests', () => {
// Trước khi chạy các test, có thể cần setup DB hoặc server
beforeAll(async () => {
// Ví dụ: Kết nối database test
// await setupTestDatabase();
});
// Sau khi chạy xong tất cả các test, có thể cần dọn dẹp
afterAll(async () => {
// Ví dụ: Đóng kết nối database test
// await closeTestDatabase();
});
// Trước mỗi test, có thể cần reset trạng thái (ví dụ: xóa dữ liệu test)
beforeEach(async () => {
// Ví dụ: Xóa dữ liệu trong các bảng cần thiết
// await clearTestDatabase();
});
// Sau mỗi test, có thể cần dọn dẹp riêng cho test đó
afterEach(async () => {
// Ví dụ: Dọn dẹp dữ liệu cụ thể được tạo ra bởi test này
});
// --- Các test case cụ thể ---
it('nên trả về danh sách người dùng thành công', async () => {
// Test case cho GET /api/users
});
it('nên tạo người dùng mới thành công', async () => {
// Test case cho POST /api/users
});
it('nên trả về lỗi 404 khi không tìm thấy người dùng', async () => {
// Test case cho GET /api/users/:id với id không tồn tại
});
// ... các test case khác
});
Bây giờ, hãy xem xét một vài ví dụ cụ thể.
Ví dụ 1: Kiểm tra GET Request (Lấy dữ liệu)
Giả sử chúng ta có một route GET /api/users
để lấy danh sách tất cả người dùng.
// tests/api/users.test.js
const request = require('supertest');
const app = require('../src/app'); // Your Express app instance
describe('GET /api/users', () => {
it('nên trả về status 200 và một mảng người dùng', async () => {
// Gửi request GET đến endpoint /api/users
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/) // Kiểm tra header Content-Type là json
.expect(200); // Kiểm tra status code là 200 OK
// Kiểm tra body của response
expect(Array.isArray(response.body)).toBe(true); // Body phải là một mảng
// Có thể kiểm tra cấu trúc của các đối tượng trong mảng
// if (response.body.length > 0) {
// expect(response.body[0]).toHaveProperty('id');
// expect(response.body[0]).toHaveProperty('name');
// expect(response.body[0]).toHaveProperty('email');
// }
});
// Thêm các test case cho các trường hợp lỗi khác (ví dụ: cần xác thực)
it('nên trả về status 401 nếu không được xác thực', async () => {
// Giả sử route này cần xác thực và middleware auth được áp dụng
// Test khi không gửi token xác thực
await request(app)
.get('/api/users-protected') // Một route giả định cần bảo vệ
.expect(401); // Kiểm tra status code là 401 Unauthorized
});
});
Giải thích:
request(app)
: Tạo một instance supertest để gửi request đến ứng dụng Expressapp
của bạn..get('/api/users')
: Chỉ định phương thức HTTP là GET và đường dẫn là/api/users
..expect('Content-Type', /json/)
: Kiểm tra rằng response headerContent-Type
chứa chuỗi "json"..expect(200)
: Kiểm tra rằng HTTP status code trả về là 200. Supertest cung cấp các helper tiện lợi cho việc kiểm tra status code.await ...
: Sử dụngasync/await
giúp code trông gọn gàng hơn khi làm việc với promise màrequest
trả về.response.body
: Truy cập vào body của response.expect(...)
: Sử dụng các matcher của Jest để kiểm tra giá trị hoặc cấu trúc của response body.
Ví dụ 2: Kiểm tra POST Request (Tạo dữ liệu)
Giả sử chúng ta có route POST /api/users
để tạo người dùng mới.
// tests/api/users.test.js (tiếp tục)
describe('POST /api/users', () => {
it('nên tạo người dùng mới và trả về status 201', async () => {
const newUser = {
name: 'Test User',
email: 'testuser@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/users') // Gửi request POST
.send(newUser) // Gửi dữ liệu người dùng mới trong body request (dạng JSON)
.expect('Content-Type', /json/)
.expect(201); // Kiểm tra status code là 201 Created
// Kiểm tra body của response (thường sẽ trả về thông tin người dùng vừa tạo, có thể kèm ID)
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(newUser.name);
expect(response.body.email).toBe(newUser.email);
// *** Quan trọng: Kiểm tra trong database ***
// Trong một Integration test đầy đủ, bạn nên kiểm tra xem người dùng này
// thực sự đã được thêm vào database hay chưa.
// const userInDb = await findUserById(response.body.id); // Hàm giả định
// expect(userInDb).not.toBeNull();
// expect(userInDb.email).toBe(newUser.email);
});
it('nên trả về status 400 nếu dữ liệu đầu vào không hợp lệ', async () => {
const invalidUser = { // Thiếu trường 'email'
name: 'Invalid User',
password: 'password123'
};
await request(app)
.post('/api/users')
.send(invalidUser)
.expect('Content-Type', /json/)
.expect(400); // Kiểm tra status code là 400 Bad Request
// Có thể kiểm tra nội dung lỗi trả về
// expect(response.body).toHaveProperty('message');
// expect(response.body.message).toContain('Email is required');
});
});
Giải thích:
.post('/api/users')
: Chỉ định phương thức POST..send(newUser)
: Gửi dữ liệunewUser
trong body của request. Supertest sẽ tự động thiết lập headerContent-Type
làapplication/json
và serialize đối tượng JavaScript thành chuỗi JSON..expect(201)
: Kiểm tra status code 201, thường dùng cho các yêu cầu tạo tài nguyên thành công.- Kiểm tra
response.body
: Xác minh rằng API trả về thông tin cần thiết của tài nguyên vừa được tạo. - Kiểm tra trong database: Phần code được comment (
findUserById
) minh họa rằng trong các Integration tests thực tế, bạn nên kiểm tra xem dữ liệu có được lưu vào database đúng cách hay không. Đây là một phần cốt lõi của Integration test (kiểm tra sự tích hợp với DB). - Test case lỗi 400: Minh họa cách test khi gửi dữ liệu không hợp lệ và mong đợi nhận được phản hồi lỗi.
Ví dụ 3: Kiểm tra PUT/PATCH Request (Cập nhật dữ liệu)
Giả sử có route PUT /api/users/:id
hoặc PATCH /api/users/:id
để cập nhật người dùng.
// tests/api/users.test.js (tiếp tục)
describe('PUT /api/users/:id', () => {
let existingUserId; // Biến để lưu ID của người dùng test
// Trước test này, tạo một người dùng test để có dữ liệu để cập nhật
beforeEach(async () => {
const newUser = { name: 'Existing User', email: 'existing@example.com', password: 'password123' };
const response = await request(app).post('/api/users').send(newUser);
existingUserId = response.body.id; // Lưu lại ID của người dùng vừa tạo
});
// Sau test này, dọn dẹp người dùng test
afterEach(async () => {
// Giả sử có hàm xóa người dùng theo ID
// await deleteUserById(existingUserId);
});
it('nên cập nhật người dùng và trả về status 200', async () => {
const updatedData = { name: 'Updated User Name' };
const response = await request(app)
.put(`/api/users/${existingUserId}`) // Gửi request PUT đến ID cụ thể
.send(updatedData)
.expect('Content-Type', /json/)
.expect(200); // Kiểm tra status code là 200 OK
expect(response.body.id).toBe(existingUserId);
expect(response.body.name).toBe(updatedData.name);
// Các trường khác không được thay đổi nếu không gửi lên
expect(response.body.email).toBe('existing@example.com');
// *** Quan trọng: Kiểm tra trong database ***
// const userInDb = await findUserById(existingUserId); // Hàm giả định
// expect(userInDb).not.toBeNull();
// expect(userInDb.name).toBe(updatedData.name);
});
it('nên trả về status 404 nếu người dùng không tồn tại', async () => {
const nonExistentId = 'non-existent-id-123'; // Một ID không tồn tại
const updatedData = { name: 'Should Fail' };
await request(app)
.put(`/api/users/${nonExistentId}`)
.send(updatedData)
.expect(404); // Kiểm tra status code là 404 Not Found
});
it('nên trả về status 400 nếu dữ liệu cập nhật không hợp lệ', async () => {
const invalidUpdatedData = { email: 'invalid-email' }; // Định dạng email sai
await request(app)
.put(`/api/users/${existingUserId}`)
.send(invalidUpdatedData)
.expect(400); // Kiểm tra status code là 400 Bad Request
});
});
Giải thích:
- Sử dụng
beforeEach
vàafterEach
: Đây là các hook của Jest.beforeEach
chạy trước mỗi test case trong khốidescribe
, giúp tạo ra một môi trường test sạch (ở đây là tạo một người dùng test).afterEach
chạy sau mỗi test case, giúp dọn dẹp dữ liệu test. Việc này rất quan trọng để đảm bảo các test case là độc lập và không ảnh hưởng lẫn nhau. .put(
/api/users/${existingUserId})
: Gửi request PUT đến một URL động có chứa ID của người dùng..send(updatedData)
: Gửi dữ liệu cần cập nhật.- Kiểm tra 404 và 400: Tương tự như POST, kiểm tra các trường hợp lỗi phổ biến.
Ví dụ 4: Kiểm tra DELETE Request (Xóa dữ liệu)
Giả sử có route DELETE /api/users/:id
để xóa người dùng.
// tests/api/users.test.js (tiếp tục)
describe('DELETE /api/users/:id', () => {
let userToDeleteId; // Biến để lưu ID của người dùng test
// Trước test này, tạo một người dùng test để có dữ liệu để xóa
beforeEach(async () => {
const newUser = { name: 'User to Delete', email: 'todelete@example.com', password: 'password123' };
const response = await request(app).post('/api/users').send(newUser);
userToDeleteId = response.body.id; // Lưu lại ID
});
// Không cần afterEach nếu test này xác nhận xóa thành công
it('nên xóa người dùng và trả về status 204 hoặc 200/20X thành công', async () => {
await request(app)
.delete(`/api/users/${userToDeleteId}`) // Gửi request DELETE
// Status code cho DELETE thành công có thể là 200 (OK), 202 (Accepted),
// hoặc phổ biến là 204 (No Content) nếu không trả về body.
// Tùy thuộc vào thiết kế API của bạn.
.expect(204); // Giả sử API trả về 204 No Content
});
it('nên trả về status 404 nếu người dùng không tồn tại', async () => {
const nonExistentId = 'non-existent-id-456'; // Một ID không tồn tại
await request(app)
.delete(`/api/users/${nonExistentId}`)
.expect(404); // Kiểm tra status code là 404 Not Found
});
// Có thể thêm test case cho quyền truy cập (403 Forbidden) nếu cần
});
Giải thích:
.delete(
/api/users/${userToDeleteId})
: Gửi request DELETE..expect(204)
: Kiểm tra status code. Status 204 No Content là mã phổ biến cho yêu cầu DELETE thành công mà không trả về nội dung trong body.- Quan trọng là test trường hợp không tìm thấy tài nguyên (404).
Comments