Skip to content

Commit b3fd8d4

Browse files
committed
Genric message
1 parent 490b947 commit b3fd8d4

8 files changed

Lines changed: 1143 additions & 2 deletions

File tree

.github/instructions/testing.instructions.md

Lines changed: 740 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This repository contains a comprehensive guide and examples for Node.js testing best practices, focusing on integration/component testing. It includes both educational content and a practical example application that demonstrates modern testing techniques.
8+
9+
## Commands
10+
11+
### Testing
12+
- `npm test` - Run Jest tests (default test runner)
13+
- `npm run test:vitest` - Run Vitest tests (alternative test runner)
14+
- `npm run test:dev` - Run Jest in watch mode with optimized settings (2 workers, silent)
15+
- `npm run test:dev:debug` - Run Jest in debug mode with inspector on port 9229
16+
- `npm run test:dev:verbose` - Run Jest in watch mode with verbose output
17+
- `npm run test:nestjs` - Run NestJS-specific tests
18+
19+
### Database
20+
- `npm run db:migrate` - Run database migrations (uses Sequelize CLI)
21+
- `npm run db:seed` - Seed database with initial data
22+
23+
### Code Quality
24+
- `npm run lint` - Run ESLint on the codebase
25+
26+
## Architecture
27+
28+
### Example Application Structure
29+
The repository contains an example Node.js application demonstrating testing best practices:
30+
31+
- **Entry Points** (`example-application/entry-points/`): API server (Express.js) and message queue consumer
32+
- **Business Logic** (`example-application/business-logic/`): Core order service logic
33+
- **Data Access** (`example-application/data-access/`): Database repository layer with Sequelize ORM
34+
- **Libraries** (`example-application/libraries/`): Shared utilities (authentication, logging, message queue client, etc.)
35+
36+
### Testing Architecture
37+
38+
**Dual Test Framework Support:**
39+
- **Jest** (primary): Configured in `jest.config.js` with comprehensive watch plugins
40+
- **Vitest** (alternative): Configured in `vitest.config.ts` for modern ESM support
41+
42+
**Test Organization:**
43+
- Jest tests: `example-application/test/jest/*.test.ts`
44+
- Vitest tests: `example-application/test/vitest/*.spec.ts`
45+
- Setup files: `example-application/test/setup/`
46+
47+
**Infrastructure Setup:**
48+
- Docker Compose for PostgreSQL database (port 54310)
49+
- Global setup automatically starts database if not running
50+
- Database migrations and seeding via npm scripts
51+
- Smart cleanup: database persists in dev, cleaned up in CI
52+
53+
**Testing Philosophy:**
54+
- **Component/Integration First**: Tests focus on entire API endpoints with real database
55+
- **Minimal E2E**: Only 3-10 E2E tests for configuration/infrastructure issues
56+
- **Selective Unit Tests**: Only for complex algorithms or non-trivial logic
57+
- **Feature-Focused**: Tests cover features/routes, not individual functions
58+
59+
### Key Patterns
60+
61+
**Database Testing:**
62+
- Tests use real PostgreSQL database, not mocks
63+
- Global setup handles Docker container lifecycle
64+
- Database cleanup occurs probabilistically (10% chance) in dev, always in CI
65+
- Migration and seeding handled by npm scripts
66+
67+
**Test Data:**
68+
- Data factories in `example-application/test/order-data-factory.ts`
69+
- Metadata seeded once, test data created per test
70+
- No shared test data between tests
71+
72+
**Error Handling:**
73+
- Centralized error handler in `example-application/error-handling.js`
74+
- Process-level uncaught exception/rejection handling
75+
76+
## Development Notes
77+
78+
- The repository demonstrates the "Testing Diamond" strategy prioritizing component tests
79+
- Anti-pattern examples are excluded from test runs by default (see jest.config.js)
80+
- Performance tests are also excluded by default
81+
- TypeScript support with ts-jest transformer
82+
- Rich Jest watch mode with plugins for filtering, repeating, and suspending tests

example-application/business-logic/order-service.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,72 @@ module.exports.getOrder = async function (id) {
6262
return await new OrderRepository().getOrderById(id);
6363
};
6464

65+
module.exports.updateOrder = async function (id, orderUpdates) {
66+
await validateUpdateRequest(id, orderUpdates);
67+
68+
const existingOrder = await getExistingOrder(id);
69+
await validateUserIfUpdated(orderUpdates, existingOrder);
70+
71+
const processedUpdates = applyBusinessLogic(orderUpdates, existingOrder);
72+
73+
return await new OrderRepository().updateOrder(id, processedUpdates);
74+
};
75+
76+
async function validateUpdateRequest(id, orderUpdates) {
77+
if (!id) {
78+
throw new AppError('invalid-id', 'No order ID specified', 400);
79+
}
80+
81+
if (!orderUpdates || Object.keys(orderUpdates).length === 0) {
82+
throw new AppError('invalid-update', 'No update data provided', 400);
83+
}
84+
85+
if (orderUpdates.id !== undefined) {
86+
throw new AppError('invalid-field', 'Cannot update order ID', 400);
87+
}
88+
89+
if (orderUpdates.productId !== undefined && !orderUpdates.productId) {
90+
throw new AppError('invalid-order', 'Product ID cannot be empty', 400);
91+
}
92+
}
93+
94+
async function getExistingOrder(id) {
95+
const existingOrder = await new OrderRepository().getOrderById(id);
96+
if (!existingOrder) {
97+
throw new AppError('order-not-found', `Order with ID ${id} not found`, 404);
98+
}
99+
return existingOrder;
100+
}
101+
102+
async function validateUserIfUpdated(orderUpdates, existingOrder) {
103+
if (orderUpdates.userId !== undefined && orderUpdates.userId !== existingOrder.userId) {
104+
const userWhoOrdered = await getUserFromUsersService(orderUpdates.userId);
105+
if (!userWhoOrdered) {
106+
throw new AppError(
107+
'user-doesnt-exist',
108+
`The user ${orderUpdates.userId} doesnt exist`,
109+
404,
110+
);
111+
}
112+
}
113+
}
114+
115+
function applyBusinessLogic(orderUpdates, existingOrder) {
116+
const processedUpdates = { ...orderUpdates };
117+
118+
if (processedUpdates.totalPrice !== undefined) {
119+
const isPremium = processedUpdates.isPremiumUser !== undefined
120+
? processedUpdates.isPremiumUser
121+
: existingOrder.isPremiumUser;
122+
123+
if (isPremium) {
124+
processedUpdates.totalPrice = Math.ceil(processedUpdates.totalPrice * 0.9);
125+
}
126+
}
127+
128+
return processedUpdates;
129+
}
130+
65131
async function getUserFromUsersService(userId) {
66132
try {
67133
const getUserResponse = await axiosHTTPClient.get(

example-application/data-access/order-repository.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ module.exports = class OrderRepository {
6161
return;
6262
}
6363

64+
async updateOrder(id, orderDetails) {
65+
await orderModel.update(orderDetails, {
66+
where: { id },
67+
});
68+
69+
return await this.getOrderById(id);
70+
}
71+
6472
async cleanup() {
6573
await orderModel.truncate();
6674
}

example-application/entry-points/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ const defineRoutes = (expressApp: express.Application) => {
7777
res.json(response);
7878
});
7979

80+
// update order by id
81+
router.put('/:id', async (req, res, next) => {
82+
try {
83+
console.log(`Order API was called to update order ${req.params.id} with ${util.inspect(req.body)}`);
84+
const updatedOrder = await orderService.updateOrder(req.params.id, req.body);
85+
res.json(updatedOrder);
86+
} catch (error) {
87+
next(error);
88+
}
89+
});
90+
8091
// delete order by id
8192
router.delete('/:id', async (req, res, next) => {
8293
console.log(`Order API was called to delete order ${req.params.id}`);
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { buildOrder } from '../order-data-factory';
2+
import { testSetup } from '../setup/test-file-setup';
3+
4+
beforeAll(async () => {
5+
await testSetup.start({
6+
startAPI: true,
7+
disableNetConnect: true,
8+
includeTokenInHttpClient: true,
9+
mockGetUserCalls: true,
10+
mockMailerCalls: true,
11+
});
12+
});
13+
14+
beforeEach(() => {
15+
testSetup.resetBeforeEach();
16+
});
17+
18+
afterAll(async () => {
19+
testSetup.tearDownTestFile();
20+
});
21+
22+
describe('PUT /order/:id', () => {
23+
test('When updating an existing order with valid data, Then should return updated order', async () => {
24+
// Arrange
25+
const originalOrder = buildOrder();
26+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
27+
const updates = {
28+
totalPrice: 150,
29+
mode: 'pending',
30+
contactEmail: 'updated@example.com'
31+
};
32+
33+
// Act
34+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
35+
36+
// Assert
37+
expect(updateResponse.status).toBe(200);
38+
expect(updateResponse.data).toMatchObject({
39+
id: createdOrder.id,
40+
userId: originalOrder.userId,
41+
productId: originalOrder.productId,
42+
isPremiumUser: originalOrder.isPremiumUser,
43+
externalIdentifier: originalOrder.externalIdentifier,
44+
...updates,
45+
});
46+
});
47+
48+
test('When updating premium user order with new price, Then should apply discount', async () => {
49+
// Arrange
50+
const originalOrder = buildOrder({ isPremiumUser: true, totalPrice: 100 });
51+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
52+
const updates = { totalPrice: 200 };
53+
54+
// Act
55+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
56+
57+
// Assert
58+
expect(updateResponse.status).toBe(200);
59+
expect(updateResponse.data.totalPrice).toBe(180); // 200 * 0.9 = 180
60+
});
61+
62+
test('When updating order to premium user, Then existing price should not change', async () => {
63+
// Arrange
64+
const originalOrder = buildOrder({ isPremiumUser: false, totalPrice: 100 });
65+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
66+
const updates = { isPremiumUser: true };
67+
68+
// Act
69+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
70+
71+
// Assert
72+
expect(updateResponse.status).toBe(200);
73+
expect(updateResponse.data.totalPrice).toBe(100); // Price unchanged since totalPrice not updated
74+
expect(updateResponse.data.isPremiumUser).toBe(true);
75+
});
76+
77+
test('When updating both price and premium status, Then should apply discount', async () => {
78+
// Arrange
79+
const originalOrder = buildOrder({ isPremiumUser: false, totalPrice: 100 });
80+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
81+
const updates = { isPremiumUser: true, totalPrice: 200 };
82+
83+
// Act
84+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
85+
86+
// Assert
87+
expect(updateResponse.status).toBe(200);
88+
expect(updateResponse.data.totalPrice).toBe(180); // 200 * 0.9 = 180
89+
expect(updateResponse.data.isPremiumUser).toBe(true);
90+
});
91+
92+
test('When updated order can be retrieved, Then should return the updated data', async () => {
93+
// Arrange
94+
const originalOrder = buildOrder();
95+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
96+
const updates = { mode: 'cancelled', contactEmail: 'cancelled@example.com' };
97+
98+
// Act
99+
await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
100+
101+
// Assert - verify state persistence
102+
const getResponse = await testSetup.getHTTPClient().get(`/order/${createdOrder.id}`);
103+
expect(getResponse.status).toBe(200);
104+
expect(getResponse.data).toMatchObject({
105+
id: createdOrder.id,
106+
userId: originalOrder.userId,
107+
productId: originalOrder.productId,
108+
isPremiumUser: originalOrder.isPremiumUser,
109+
externalIdentifier: originalOrder.externalIdentifier,
110+
...updates,
111+
});
112+
});
113+
});
114+
115+
describe('PUT /order/:id - Validation Tests', () => {
116+
test('When updating non-existent order, Then should return 404', async () => {
117+
// Arrange
118+
const nonExistentId = 99999;
119+
const updates = { mode: 'pending' };
120+
121+
// Act & Assert
122+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${nonExistentId}`, updates);
123+
expect(updateResponse.status).toBe(404);
124+
});
125+
126+
test('When updating with empty data, Then should return 400', async () => {
127+
// Arrange
128+
const originalOrder = buildOrder();
129+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
130+
131+
// Act & Assert
132+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, {});
133+
expect(updateResponse.status).toBe(400);
134+
});
135+
136+
test('When trying to update order ID, Then should return 400', async () => {
137+
// Arrange
138+
const originalOrder = buildOrder();
139+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
140+
const updates = { id: 12345, mode: 'pending' };
141+
142+
// Act & Assert
143+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
144+
expect(updateResponse.status).toBe(400);
145+
});
146+
147+
test('When updating with empty productId, Then should return 400', async () => {
148+
// Arrange
149+
const originalOrder = buildOrder();
150+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
151+
const updates = { productId: null };
152+
153+
// Act & Assert
154+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
155+
expect(updateResponse.status).toBe(400);
156+
});
157+
158+
test('When updating with non-existent userId, Then should return 404', async () => {
159+
// Arrange
160+
const originalOrder = buildOrder();
161+
const { data: createdOrder } = await testSetup.getHTTPClienForArrange().post('/order', originalOrder);
162+
const updates = { userId: 99999 };
163+
164+
// Act & Assert
165+
const updateResponse = await testSetup.getHTTPClient().put(`/order/${createdOrder.id}`, updates);
166+
expect(updateResponse.status).toBe(404);
167+
});
168+
169+
test('When updating without order ID in URL, Then should return 404', async () => {
170+
// Arrange
171+
const updates = { mode: 'pending' };
172+
173+
// Act & Assert
174+
const updateResponse = await testSetup.getHTTPClient().put('/order/', updates);
175+
expect(updateResponse.status).toBe(404); // Express returns 404 for missing route parameter
176+
});
177+
});

0 commit comments

Comments
 (0)