@su-record/vibe 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/explorer.md +48 -48
- package/.claude/agents/implementer.md +53 -53
- package/.claude/agents/searcher.md +54 -54
- package/.claude/agents/simplifier.md +119 -119
- package/.claude/agents/tester.md +49 -49
- package/.claude/commands/vibe.analyze.md +239 -239
- package/.claude/commands/vibe.continue.md +88 -88
- package/.claude/commands/vibe.diagram.md +178 -178
- package/.claude/commands/vibe.reason.md +306 -306
- package/.claude/commands/vibe.run.md +760 -760
- package/.claude/commands/vibe.spec.md +339 -339
- package/.claude/commands/vibe.tool.md +153 -153
- package/.claude/commands/vibe.ui.md +137 -137
- package/.claude/commands/vibe.verify.md +238 -238
- package/.claude/settings.json +152 -152
- package/.claude/settings.local.json +4 -57
- package/.vibe/config.json +9 -0
- package/.vibe/constitution.md +184 -184
- package/.vibe/rules/core/communication-guide.md +104 -104
- package/.vibe/rules/core/development-philosophy.md +52 -52
- package/.vibe/rules/core/quick-start.md +120 -120
- package/.vibe/rules/quality/bdd-contract-testing.md +388 -388
- package/.vibe/rules/quality/checklist.md +276 -276
- package/.vibe/rules/quality/testing-strategy.md +437 -437
- package/.vibe/rules/standards/anti-patterns.md +369 -369
- package/.vibe/rules/standards/code-structure.md +291 -291
- package/.vibe/rules/standards/complexity-metrics.md +312 -312
- package/.vibe/rules/standards/naming-conventions.md +198 -198
- package/.vibe/rules/tools/mcp-hi-ai-guide.md +665 -665
- package/.vibe/rules/tools/mcp-workflow.md +51 -51
- package/.vibe/setup.sh +31 -31
- package/CLAUDE.md +122 -122
- package/LICENSE +21 -21
- package/README.md +568 -568
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +391 -406
- package/dist/cli/index.js.map +1 -1
- package/dist/lib/MemoryManager.js +92 -92
- package/dist/lib/PythonParser.js +108 -108
- package/dist/lib/gemini-mcp.js +15 -15
- package/dist/lib/gemini-oauth.js +35 -35
- package/dist/lib/gpt-mcp.js +17 -17
- package/dist/lib/gpt-oauth.js +44 -44
- package/dist/tools/analytics/getUsageAnalytics.js +12 -12
- package/dist/tools/memory/createMemoryTimeline.js +10 -10
- package/dist/tools/memory/getMemoryGraph.js +12 -12
- package/dist/tools/memory/getSessionContext.js +9 -9
- package/dist/tools/memory/linkMemories.js +14 -14
- package/dist/tools/memory/listMemories.js +4 -4
- package/dist/tools/memory/recallMemory.js +4 -4
- package/dist/tools/memory/saveMemory.js +4 -4
- package/dist/tools/memory/searchMemoriesAdvanced.js +22 -22
- package/dist/tools/planning/generatePrd.js +46 -46
- package/dist/tools/prompt/enhancePromptGemini.js +160 -160
- package/dist/tools/reasoning/applyReasoningFramework.js +56 -56
- package/dist/tools/semantic/analyzeDependencyGraph.js +12 -12
- package/package.json +67 -67
- package/templates/constitution-template.md +184 -184
- package/templates/contract-backend-template.md +517 -517
- package/templates/contract-frontend-template.md +594 -594
- package/templates/feature-template.md +96 -96
- package/templates/hooks-template.json +103 -103
- package/templates/spec-template.md +199 -199
- package/dist/lib/vibe-mcp.d.ts.map +0 -1
- package/dist/lib/vibe-mcp.js.map +0 -1
|
@@ -1,594 +1,594 @@
|
|
|
1
|
-
# Frontend Contract Tests: {기능명}
|
|
2
|
-
|
|
3
|
-
**Generated from**: `specs/{기능명}.md` (Section 6: API 계약)
|
|
4
|
-
**Framework**: {Flutter | React | React Native | Vue}
|
|
5
|
-
**Language**: {Dart | TypeScript | JavaScript}
|
|
6
|
-
**Priority**: {HIGH | MEDIUM | LOW}
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## Overview
|
|
11
|
-
|
|
12
|
-
Frontend Contract Testing은 **Consumer 관점에서 API 계약을 검증**합니다:
|
|
13
|
-
- ✅ API 요청이 계약에 맞게 전송되는지
|
|
14
|
-
- ✅ API 응답이 예상 스키마를 따르는지
|
|
15
|
-
- ✅ 에러 처리가 계약대로 동작하는지
|
|
16
|
-
- ✅ Mock 서버로 독립적 테스트 가능
|
|
17
|
-
|
|
18
|
-
**Consumer-Driven Contract Testing** (Pact 패턴)
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## API Contracts (Consumer View)
|
|
23
|
-
|
|
24
|
-
### Contract 1: Create Resource
|
|
25
|
-
|
|
26
|
-
**Consumer Expectation**:
|
|
27
|
-
```json
|
|
28
|
-
{
|
|
29
|
-
"request": {
|
|
30
|
-
"method": "POST",
|
|
31
|
-
"path": "/api/v1/resource",
|
|
32
|
-
"headers": {
|
|
33
|
-
"Authorization": "Bearer {token}",
|
|
34
|
-
"Content-Type": "application/json"
|
|
35
|
-
},
|
|
36
|
-
"body": {
|
|
37
|
-
"field1": "string",
|
|
38
|
-
"field2": "integer"
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
"response": {
|
|
42
|
-
"status": 201,
|
|
43
|
-
"body": {
|
|
44
|
-
"id": "uuid",
|
|
45
|
-
"field1": "string",
|
|
46
|
-
"field2": "integer",
|
|
47
|
-
"createdAt": "datetime"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## Implementation
|
|
56
|
-
|
|
57
|
-
### Flutter (Dart + http_mock_adapter)
|
|
58
|
-
|
|
59
|
-
**File**: `test/contract/{기능명}_contract_test.dart`
|
|
60
|
-
|
|
61
|
-
```dart
|
|
62
|
-
import 'package:flutter_test/flutter_test.dart';
|
|
63
|
-
import 'package:dio/dio.dart';
|
|
64
|
-
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
|
65
|
-
import 'package:your_app/services/api_service.dart';
|
|
66
|
-
import 'package:your_app/models/resource.dart';
|
|
67
|
-
|
|
68
|
-
void main() {
|
|
69
|
-
late Dio dio;
|
|
70
|
-
late DioAdapter dioAdapter;
|
|
71
|
-
late ApiService apiService;
|
|
72
|
-
|
|
73
|
-
setUp(() {
|
|
74
|
-
dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
|
|
75
|
-
dioAdapter = DioAdapter(dio: dio);
|
|
76
|
-
apiService = ApiService(dio: dio);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
group('Create Resource Contract', () {
|
|
80
|
-
test('should match request contract', () async {
|
|
81
|
-
// Arrange: Expected request contract
|
|
82
|
-
final requestBody = {
|
|
83
|
-
'field1': 'test value',
|
|
84
|
-
'field2': 42,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// Arrange: Mock response matching contract
|
|
88
|
-
final responseBody = {
|
|
89
|
-
'id': '123e4567-e89b-12d3-a456-426614174000',
|
|
90
|
-
'field1': 'test value',
|
|
91
|
-
'field2': 42,
|
|
92
|
-
'createdAt': '2025-01-17T10:00:00Z',
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
dioAdapter.onPost(
|
|
96
|
-
'/api/v1/resource',
|
|
97
|
-
(server) => server.reply(201, responseBody),
|
|
98
|
-
data: requestBody,
|
|
99
|
-
headers: {
|
|
100
|
-
'Authorization': 'Bearer test-token',
|
|
101
|
-
'Content-Type': 'application/json',
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
// Act: Call API service
|
|
106
|
-
final result = await apiService.createResource(
|
|
107
|
-
field1: 'test value',
|
|
108
|
-
field2: 42,
|
|
109
|
-
token: 'test-token',
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
// Assert: Response matches contract
|
|
113
|
-
expect(result, isA<Resource>());
|
|
114
|
-
expect(result.id, isNotEmpty);
|
|
115
|
-
expect(result.field1, equals('test value'));
|
|
116
|
-
expect(result.field2, equals(42));
|
|
117
|
-
expect(result.createdAt, isA<DateTime>());
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('should handle error response contract', () async {
|
|
121
|
-
// Arrange: Error response contract
|
|
122
|
-
final errorBody = {
|
|
123
|
-
'error': 'ValidationError',
|
|
124
|
-
'message': 'field1 is required',
|
|
125
|
-
'details': ['field1 must not be empty'],
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
dioAdapter.onPost(
|
|
129
|
-
'/api/v1/resource',
|
|
130
|
-
(server) => server.reply(400, errorBody),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
// Act & Assert: Error handling matches contract
|
|
134
|
-
expect(
|
|
135
|
-
() async => await apiService.createResource(
|
|
136
|
-
field1: '',
|
|
137
|
-
field2: 42,
|
|
138
|
-
token: 'test-token',
|
|
139
|
-
),
|
|
140
|
-
throwsA(isA<ApiException>().having(
|
|
141
|
-
(e) => e.statusCode,
|
|
142
|
-
'status code',
|
|
143
|
-
equals(400),
|
|
144
|
-
)),
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test('should validate response schema', () async {
|
|
149
|
-
// Arrange: Response with invalid schema
|
|
150
|
-
final invalidResponse = {
|
|
151
|
-
'id': 'not-a-uuid', // Invalid UUID format
|
|
152
|
-
'field1': 123, // Wrong type
|
|
153
|
-
// Missing field2
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
dioAdapter.onPost(
|
|
157
|
-
'/api/v1/resource',
|
|
158
|
-
(server) => server.reply(201, invalidResponse),
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
// Act & Assert: Schema validation fails
|
|
162
|
-
expect(
|
|
163
|
-
() async => await apiService.createResource(
|
|
164
|
-
field1: 'test',
|
|
165
|
-
field2: 42,
|
|
166
|
-
token: 'test-token',
|
|
167
|
-
),
|
|
168
|
-
throwsA(isA<SchemaValidationException>()),
|
|
169
|
-
);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
group('Response Schema Validation', () {
|
|
174
|
-
test('validates UUID format', () {
|
|
175
|
-
final validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
|
176
|
-
expect(isValidUuid(validUuid), isTrue);
|
|
177
|
-
|
|
178
|
-
final invalidUuid = 'not-a-uuid';
|
|
179
|
-
expect(isValidUuid(invalidUuid), isFalse);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test('validates DateTime format (ISO 8601)', () {
|
|
183
|
-
final validDateTime = '2025-01-17T10:00:00Z';
|
|
184
|
-
expect(() => DateTime.parse(validDateTime), returnsNormally);
|
|
185
|
-
|
|
186
|
-
final invalidDateTime = '2025-01-17'; // Missing time
|
|
187
|
-
expect(() => DateTime.parse(invalidDateTime), throwsFormatException);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Helper function
|
|
193
|
-
bool isValidUuid(String uuid) {
|
|
194
|
-
final uuidRegex = RegExp(
|
|
195
|
-
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
|
196
|
-
caseSensitive: false,
|
|
197
|
-
);
|
|
198
|
-
return uuidRegex.hasMatch(uuid);
|
|
199
|
-
}
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
**Run**:
|
|
203
|
-
```bash
|
|
204
|
-
flutter test test/contract/{기능명}_contract_test.dart
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
---
|
|
208
|
-
|
|
209
|
-
### React (TypeScript + MSW + Zod)
|
|
210
|
-
|
|
211
|
-
**File**: `tests/contract/{기능명}.contract.test.ts`
|
|
212
|
-
|
|
213
|
-
```typescript
|
|
214
|
-
import { rest } from 'msw';
|
|
215
|
-
import { setupServer } from 'msw/node';
|
|
216
|
-
import { z } from 'zod';
|
|
217
|
-
import { createResource, ApiService } from '@/services/api';
|
|
218
|
-
|
|
219
|
-
// Zod schemas for contract validation
|
|
220
|
-
const CreateResourceRequestSchema = z.object({
|
|
221
|
-
field1: z.string().min(1).max(100),
|
|
222
|
-
field2: z.number().int().nonnegative(),
|
|
223
|
-
field3: z.boolean().optional(),
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
const CreateResourceResponseSchema = z.object({
|
|
227
|
-
id: z.string().uuid(),
|
|
228
|
-
field1: z.string(),
|
|
229
|
-
field2: z.number().int(),
|
|
230
|
-
field3: z.boolean().optional(),
|
|
231
|
-
createdAt: z.string().datetime(),
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
const ErrorResponseSchema = z.object({
|
|
235
|
-
error: z.string(),
|
|
236
|
-
message: z.string(),
|
|
237
|
-
details: z.array(z.string()).optional(),
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// Mock server
|
|
241
|
-
const server = setupServer();
|
|
242
|
-
|
|
243
|
-
beforeAll(() => server.listen());
|
|
244
|
-
afterEach(() => server.resetHandlers());
|
|
245
|
-
afterAll(() => server.close());
|
|
246
|
-
|
|
247
|
-
describe('Create Resource Contract', () => {
|
|
248
|
-
it('should send request matching contract', async () => {
|
|
249
|
-
let capturedRequest: any;
|
|
250
|
-
|
|
251
|
-
server.use(
|
|
252
|
-
rest.post('/api/v1/resource', async (req, res, ctx) => {
|
|
253
|
-
capturedRequest = await req.json();
|
|
254
|
-
|
|
255
|
-
// Validate request matches contract
|
|
256
|
-
const result = CreateResourceRequestSchema.safeParse(capturedRequest);
|
|
257
|
-
expect(result.success).toBe(true);
|
|
258
|
-
|
|
259
|
-
return res(
|
|
260
|
-
ctx.status(201),
|
|
261
|
-
ctx.json({
|
|
262
|
-
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
263
|
-
field1: capturedRequest.field1,
|
|
264
|
-
field2: capturedRequest.field2,
|
|
265
|
-
field3: capturedRequest.field3 ?? false,
|
|
266
|
-
createdAt: new Date().toISOString(),
|
|
267
|
-
})
|
|
268
|
-
);
|
|
269
|
-
})
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
const result = await createResource({
|
|
273
|
-
field1: 'test value',
|
|
274
|
-
field2: 42,
|
|
275
|
-
field3: true,
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
// Verify request contract
|
|
279
|
-
expect(capturedRequest).toMatchObject({
|
|
280
|
-
field1: 'test value',
|
|
281
|
-
field2: 42,
|
|
282
|
-
field3: true,
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
// Verify response contract
|
|
286
|
-
const responseValidation = CreateResourceResponseSchema.safeParse(result);
|
|
287
|
-
expect(responseValidation.success).toBe(true);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it('should handle error response contract', async () => {
|
|
291
|
-
server.use(
|
|
292
|
-
rest.post('/api/v1/resource', (req, res, ctx) => {
|
|
293
|
-
return res(
|
|
294
|
-
ctx.status(400),
|
|
295
|
-
ctx.json({
|
|
296
|
-
error: 'ValidationError',
|
|
297
|
-
message: 'field1 is required',
|
|
298
|
-
details: ['field1 must not be empty'],
|
|
299
|
-
})
|
|
300
|
-
);
|
|
301
|
-
})
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
await expect(
|
|
305
|
-
createResource({
|
|
306
|
-
field1: '',
|
|
307
|
-
field2: 42,
|
|
308
|
-
})
|
|
309
|
-
).rejects.toThrow();
|
|
310
|
-
|
|
311
|
-
// Verify error response matches contract
|
|
312
|
-
try {
|
|
313
|
-
await createResource({ field1: '', field2: 42 });
|
|
314
|
-
} catch (error: any) {
|
|
315
|
-
const errorValidation = ErrorResponseSchema.safeParse(error.response.data);
|
|
316
|
-
expect(errorValidation.success).toBe(true);
|
|
317
|
-
expect(error.response.status).toBe(400);
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('should reject response with invalid schema', async () => {
|
|
322
|
-
server.use(
|
|
323
|
-
rest.post('/api/v1/resource', (req, res, ctx) => {
|
|
324
|
-
return res(
|
|
325
|
-
ctx.status(201),
|
|
326
|
-
ctx.json({
|
|
327
|
-
id: 'not-a-uuid', // Invalid UUID
|
|
328
|
-
field1: 123, // Wrong type
|
|
329
|
-
// Missing field2
|
|
330
|
-
})
|
|
331
|
-
);
|
|
332
|
-
})
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
await expect(
|
|
336
|
-
createResource({
|
|
337
|
-
field1: 'test',
|
|
338
|
-
field2: 42,
|
|
339
|
-
})
|
|
340
|
-
).rejects.toThrow('Schema validation failed');
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('validates response headers', async () => {
|
|
344
|
-
let responseHeaders: Headers;
|
|
345
|
-
|
|
346
|
-
server.use(
|
|
347
|
-
rest.post('/api/v1/resource', (req, res, ctx) => {
|
|
348
|
-
return res(
|
|
349
|
-
ctx.status(201),
|
|
350
|
-
ctx.set('Content-Type', 'application/json'),
|
|
351
|
-
ctx.json({
|
|
352
|
-
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
353
|
-
field1: 'test',
|
|
354
|
-
field2: 42,
|
|
355
|
-
createdAt: new Date().toISOString(),
|
|
356
|
-
})
|
|
357
|
-
);
|
|
358
|
-
})
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
const response = await fetch('/api/v1/resource', {
|
|
362
|
-
method: 'POST',
|
|
363
|
-
body: JSON.stringify({ field1: 'test', field2: 42 }),
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
describe('Schema Validation Utilities', () => {
|
|
371
|
-
it('validates UUID format', () => {
|
|
372
|
-
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
|
373
|
-
const result = z.string().uuid().safeParse(validUuid);
|
|
374
|
-
expect(result.success).toBe(true);
|
|
375
|
-
|
|
376
|
-
const invalidUuid = 'not-a-uuid';
|
|
377
|
-
const invalidResult = z.string().uuid().safeParse(invalidUuid);
|
|
378
|
-
expect(invalidResult.success).toBe(false);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it('validates ISO 8601 datetime', () => {
|
|
382
|
-
const validDate = '2025-01-17T10:00:00Z';
|
|
383
|
-
const result = z.string().datetime().safeParse(validDate);
|
|
384
|
-
expect(result.success).toBe(true);
|
|
385
|
-
|
|
386
|
-
const invalidDate = '2025-01-17'; // Missing time
|
|
387
|
-
const invalidResult = z.string().datetime().safeParse(invalidDate);
|
|
388
|
-
expect(invalidResult.success).toBe(false);
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
**Run**:
|
|
394
|
-
```bash
|
|
395
|
-
npm test -- tests/contract/{기능명}.contract.test.ts
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
---
|
|
399
|
-
|
|
400
|
-
### React Native (TypeScript + Axios + MockAdapter)
|
|
401
|
-
|
|
402
|
-
**File**: `__tests__/contract/{기능명}.contract.test.ts`
|
|
403
|
-
|
|
404
|
-
```typescript
|
|
405
|
-
import axios from 'axios';
|
|
406
|
-
import MockAdapter from 'axios-mock-adapter';
|
|
407
|
-
import { z } from 'zod';
|
|
408
|
-
import { ApiService } from '@/services/api';
|
|
409
|
-
|
|
410
|
-
const mock = new MockAdapter(axios);
|
|
411
|
-
|
|
412
|
-
const ResponseSchema = z.object({
|
|
413
|
-
id: z.string().uuid(),
|
|
414
|
-
field1: z.string(),
|
|
415
|
-
field2: z.number(),
|
|
416
|
-
createdAt: z.string().datetime(),
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
describe('Create Resource Contract (React Native)', () => {
|
|
420
|
-
beforeEach(() => {
|
|
421
|
-
mock.reset();
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
it('should match API contract', async () => {
|
|
425
|
-
const requestBody = {
|
|
426
|
-
field1: 'test value',
|
|
427
|
-
field2: 42,
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
const responseBody = {
|
|
431
|
-
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
432
|
-
field1: 'test value',
|
|
433
|
-
field2: 42,
|
|
434
|
-
createdAt: '2025-01-17T10:00:00Z',
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
mock.onPost('/api/v1/resource', requestBody).reply(201, responseBody);
|
|
438
|
-
|
|
439
|
-
const apiService = new ApiService(axios);
|
|
440
|
-
const result = await apiService.createResource(requestBody);
|
|
441
|
-
|
|
442
|
-
// Validate response schema
|
|
443
|
-
const validation = ResponseSchema.safeParse(result);
|
|
444
|
-
expect(validation.success).toBe(true);
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
**Run**:
|
|
450
|
-
```bash
|
|
451
|
-
npm test -- __tests__/contract/
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
---
|
|
455
|
-
|
|
456
|
-
## Pact Consumer Tests
|
|
457
|
-
|
|
458
|
-
### Flutter (dart_pact)
|
|
459
|
-
|
|
460
|
-
**File**: `test/pact/{기능명}_pact_test.dart`
|
|
461
|
-
|
|
462
|
-
```dart
|
|
463
|
-
import 'package:pact_consumer_dart/pact_consumer_dart.dart';
|
|
464
|
-
import 'package:test/test.dart';
|
|
465
|
-
|
|
466
|
-
void main() {
|
|
467
|
-
late PactMockService mockService;
|
|
468
|
-
|
|
469
|
-
setUpAll(() async {
|
|
470
|
-
mockService = PactMockService(
|
|
471
|
-
consumer: 'FrontendApp',
|
|
472
|
-
provider: 'BackendAPI',
|
|
473
|
-
port: 1234,
|
|
474
|
-
);
|
|
475
|
-
await mockService.start();
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
tearDownAll(() async {
|
|
479
|
-
await mockService.stop();
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
test('create resource contract', () async {
|
|
483
|
-
await mockService
|
|
484
|
-
.given('user is authenticated')
|
|
485
|
-
.uponReceiving('a request to create resource')
|
|
486
|
-
.withRequest(
|
|
487
|
-
method: 'POST',
|
|
488
|
-
path: '/api/v1/resource',
|
|
489
|
-
headers: {'Authorization': 'Bearer token'},
|
|
490
|
-
body: {
|
|
491
|
-
'field1': 'test value',
|
|
492
|
-
'field2': 42,
|
|
493
|
-
},
|
|
494
|
-
)
|
|
495
|
-
.willRespondWith(
|
|
496
|
-
status: 201,
|
|
497
|
-
body: {
|
|
498
|
-
'id': Matchers.uuid,
|
|
499
|
-
'field1': Matchers.string('test value'),
|
|
500
|
-
'field2': Matchers.integer(42),
|
|
501
|
-
'createdAt': Matchers.iso8601DateTime,
|
|
502
|
-
},
|
|
503
|
-
);
|
|
504
|
-
|
|
505
|
-
await mockService.run((config) async {
|
|
506
|
-
// Test your API service against mock
|
|
507
|
-
final apiService = ApiService(baseUrl: config.baseUrl);
|
|
508
|
-
final result = await apiService.createResource(
|
|
509
|
-
field1: 'test value',
|
|
510
|
-
field2: 42,
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
expect(result.id, isNotEmpty);
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
// Pact file generated: pacts/FrontendApp-BackendAPI.json
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
---
|
|
522
|
-
|
|
523
|
-
## CI/CD Integration
|
|
524
|
-
|
|
525
|
-
```yaml
|
|
526
|
-
# .github/workflows/contract-tests.yml
|
|
527
|
-
name: Frontend Contract Tests
|
|
528
|
-
|
|
529
|
-
on: [pull_request]
|
|
530
|
-
|
|
531
|
-
jobs:
|
|
532
|
-
contract-tests:
|
|
533
|
-
runs-on: ubuntu-latest
|
|
534
|
-
|
|
535
|
-
steps:
|
|
536
|
-
- uses: actions/checkout@v2
|
|
537
|
-
|
|
538
|
-
- name: Setup Flutter
|
|
539
|
-
uses: subosito/flutter-action@v2
|
|
540
|
-
with:
|
|
541
|
-
flutter-version: '3.24.0'
|
|
542
|
-
|
|
543
|
-
- name: Run contract tests
|
|
544
|
-
run: flutter test test/contract/
|
|
545
|
-
|
|
546
|
-
- name: Run Pact tests
|
|
547
|
-
run: flutter test test/pact/
|
|
548
|
-
|
|
549
|
-
- name: Publish Pact
|
|
550
|
-
if: success()
|
|
551
|
-
run: |
|
|
552
|
-
flutter pub global activate pact_broker_cli
|
|
553
|
-
pact-broker publish pacts/ \
|
|
554
|
-
--consumer-app-version=${{ github.sha }} \
|
|
555
|
-
--broker-base-url=${{ secrets.PACT_BROKER_URL }}
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
---
|
|
559
|
-
|
|
560
|
-
## Best Practices
|
|
561
|
-
|
|
562
|
-
1. **Mock 서버 활용**
|
|
563
|
-
- ✅ 백엔드 없이 독립적 테스트
|
|
564
|
-
- ✅ 계약 위반 시 즉시 감지
|
|
565
|
-
|
|
566
|
-
2. **Schema Validation**
|
|
567
|
-
- ✅ Zod, JSON Schema로 응답 검증
|
|
568
|
-
- ✅ 타입 안정성 보장
|
|
569
|
-
|
|
570
|
-
3. **Consumer-Driven**
|
|
571
|
-
- ✅ Frontend 요구사항 먼저 정의
|
|
572
|
-
- ✅ Pact 파일로 백엔드 팀과 공유
|
|
573
|
-
|
|
574
|
-
4. **CI/CD 자동화**
|
|
575
|
-
- ✅ PR마다 Contract 검증
|
|
576
|
-
- ✅ Pact Broker로 중앙 관리
|
|
577
|
-
|
|
578
|
-
---
|
|
579
|
-
|
|
580
|
-
## Next Steps
|
|
581
|
-
|
|
582
|
-
```bash
|
|
583
|
-
# 1. Contract 테스트 작성
|
|
584
|
-
vibe contract "{기능명}" --frontend
|
|
585
|
-
|
|
586
|
-
# 2. Mock 서버로 개발
|
|
587
|
-
flutter test test/contract/ --watch
|
|
588
|
-
|
|
589
|
-
# 3. Pact 생성 및 발행
|
|
590
|
-
flutter test test/pact/
|
|
591
|
-
|
|
592
|
-
# 4. 백엔드와 계약 검증
|
|
593
|
-
vibe verify "{기능명}" --contract
|
|
594
|
-
```
|
|
1
|
+
# Frontend Contract Tests: {기능명}
|
|
2
|
+
|
|
3
|
+
**Generated from**: `specs/{기능명}.md` (Section 6: API 계약)
|
|
4
|
+
**Framework**: {Flutter | React | React Native | Vue}
|
|
5
|
+
**Language**: {Dart | TypeScript | JavaScript}
|
|
6
|
+
**Priority**: {HIGH | MEDIUM | LOW}
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
Frontend Contract Testing은 **Consumer 관점에서 API 계약을 검증**합니다:
|
|
13
|
+
- ✅ API 요청이 계약에 맞게 전송되는지
|
|
14
|
+
- ✅ API 응답이 예상 스키마를 따르는지
|
|
15
|
+
- ✅ 에러 처리가 계약대로 동작하는지
|
|
16
|
+
- ✅ Mock 서버로 독립적 테스트 가능
|
|
17
|
+
|
|
18
|
+
**Consumer-Driven Contract Testing** (Pact 패턴)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## API Contracts (Consumer View)
|
|
23
|
+
|
|
24
|
+
### Contract 1: Create Resource
|
|
25
|
+
|
|
26
|
+
**Consumer Expectation**:
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"request": {
|
|
30
|
+
"method": "POST",
|
|
31
|
+
"path": "/api/v1/resource",
|
|
32
|
+
"headers": {
|
|
33
|
+
"Authorization": "Bearer {token}",
|
|
34
|
+
"Content-Type": "application/json"
|
|
35
|
+
},
|
|
36
|
+
"body": {
|
|
37
|
+
"field1": "string",
|
|
38
|
+
"field2": "integer"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"response": {
|
|
42
|
+
"status": 201,
|
|
43
|
+
"body": {
|
|
44
|
+
"id": "uuid",
|
|
45
|
+
"field1": "string",
|
|
46
|
+
"field2": "integer",
|
|
47
|
+
"createdAt": "datetime"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Implementation
|
|
56
|
+
|
|
57
|
+
### Flutter (Dart + http_mock_adapter)
|
|
58
|
+
|
|
59
|
+
**File**: `test/contract/{기능명}_contract_test.dart`
|
|
60
|
+
|
|
61
|
+
```dart
|
|
62
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
63
|
+
import 'package:dio/dio.dart';
|
|
64
|
+
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
|
65
|
+
import 'package:your_app/services/api_service.dart';
|
|
66
|
+
import 'package:your_app/models/resource.dart';
|
|
67
|
+
|
|
68
|
+
void main() {
|
|
69
|
+
late Dio dio;
|
|
70
|
+
late DioAdapter dioAdapter;
|
|
71
|
+
late ApiService apiService;
|
|
72
|
+
|
|
73
|
+
setUp(() {
|
|
74
|
+
dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
|
|
75
|
+
dioAdapter = DioAdapter(dio: dio);
|
|
76
|
+
apiService = ApiService(dio: dio);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
group('Create Resource Contract', () {
|
|
80
|
+
test('should match request contract', () async {
|
|
81
|
+
// Arrange: Expected request contract
|
|
82
|
+
final requestBody = {
|
|
83
|
+
'field1': 'test value',
|
|
84
|
+
'field2': 42,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Arrange: Mock response matching contract
|
|
88
|
+
final responseBody = {
|
|
89
|
+
'id': '123e4567-e89b-12d3-a456-426614174000',
|
|
90
|
+
'field1': 'test value',
|
|
91
|
+
'field2': 42,
|
|
92
|
+
'createdAt': '2025-01-17T10:00:00Z',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
dioAdapter.onPost(
|
|
96
|
+
'/api/v1/resource',
|
|
97
|
+
(server) => server.reply(201, responseBody),
|
|
98
|
+
data: requestBody,
|
|
99
|
+
headers: {
|
|
100
|
+
'Authorization': 'Bearer test-token',
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Act: Call API service
|
|
106
|
+
final result = await apiService.createResource(
|
|
107
|
+
field1: 'test value',
|
|
108
|
+
field2: 42,
|
|
109
|
+
token: 'test-token',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Assert: Response matches contract
|
|
113
|
+
expect(result, isA<Resource>());
|
|
114
|
+
expect(result.id, isNotEmpty);
|
|
115
|
+
expect(result.field1, equals('test value'));
|
|
116
|
+
expect(result.field2, equals(42));
|
|
117
|
+
expect(result.createdAt, isA<DateTime>());
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('should handle error response contract', () async {
|
|
121
|
+
// Arrange: Error response contract
|
|
122
|
+
final errorBody = {
|
|
123
|
+
'error': 'ValidationError',
|
|
124
|
+
'message': 'field1 is required',
|
|
125
|
+
'details': ['field1 must not be empty'],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
dioAdapter.onPost(
|
|
129
|
+
'/api/v1/resource',
|
|
130
|
+
(server) => server.reply(400, errorBody),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Act & Assert: Error handling matches contract
|
|
134
|
+
expect(
|
|
135
|
+
() async => await apiService.createResource(
|
|
136
|
+
field1: '',
|
|
137
|
+
field2: 42,
|
|
138
|
+
token: 'test-token',
|
|
139
|
+
),
|
|
140
|
+
throwsA(isA<ApiException>().having(
|
|
141
|
+
(e) => e.statusCode,
|
|
142
|
+
'status code',
|
|
143
|
+
equals(400),
|
|
144
|
+
)),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('should validate response schema', () async {
|
|
149
|
+
// Arrange: Response with invalid schema
|
|
150
|
+
final invalidResponse = {
|
|
151
|
+
'id': 'not-a-uuid', // Invalid UUID format
|
|
152
|
+
'field1': 123, // Wrong type
|
|
153
|
+
// Missing field2
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
dioAdapter.onPost(
|
|
157
|
+
'/api/v1/resource',
|
|
158
|
+
(server) => server.reply(201, invalidResponse),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Act & Assert: Schema validation fails
|
|
162
|
+
expect(
|
|
163
|
+
() async => await apiService.createResource(
|
|
164
|
+
field1: 'test',
|
|
165
|
+
field2: 42,
|
|
166
|
+
token: 'test-token',
|
|
167
|
+
),
|
|
168
|
+
throwsA(isA<SchemaValidationException>()),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
group('Response Schema Validation', () {
|
|
174
|
+
test('validates UUID format', () {
|
|
175
|
+
final validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
|
176
|
+
expect(isValidUuid(validUuid), isTrue);
|
|
177
|
+
|
|
178
|
+
final invalidUuid = 'not-a-uuid';
|
|
179
|
+
expect(isValidUuid(invalidUuid), isFalse);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('validates DateTime format (ISO 8601)', () {
|
|
183
|
+
final validDateTime = '2025-01-17T10:00:00Z';
|
|
184
|
+
expect(() => DateTime.parse(validDateTime), returnsNormally);
|
|
185
|
+
|
|
186
|
+
final invalidDateTime = '2025-01-17'; // Missing time
|
|
187
|
+
expect(() => DateTime.parse(invalidDateTime), throwsFormatException);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Helper function
|
|
193
|
+
bool isValidUuid(String uuid) {
|
|
194
|
+
final uuidRegex = RegExp(
|
|
195
|
+
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
|
196
|
+
caseSensitive: false,
|
|
197
|
+
);
|
|
198
|
+
return uuidRegex.hasMatch(uuid);
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Run**:
|
|
203
|
+
```bash
|
|
204
|
+
flutter test test/contract/{기능명}_contract_test.dart
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### React (TypeScript + MSW + Zod)
|
|
210
|
+
|
|
211
|
+
**File**: `tests/contract/{기능명}.contract.test.ts`
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import { rest } from 'msw';
|
|
215
|
+
import { setupServer } from 'msw/node';
|
|
216
|
+
import { z } from 'zod';
|
|
217
|
+
import { createResource, ApiService } from '@/services/api';
|
|
218
|
+
|
|
219
|
+
// Zod schemas for contract validation
|
|
220
|
+
const CreateResourceRequestSchema = z.object({
|
|
221
|
+
field1: z.string().min(1).max(100),
|
|
222
|
+
field2: z.number().int().nonnegative(),
|
|
223
|
+
field3: z.boolean().optional(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const CreateResourceResponseSchema = z.object({
|
|
227
|
+
id: z.string().uuid(),
|
|
228
|
+
field1: z.string(),
|
|
229
|
+
field2: z.number().int(),
|
|
230
|
+
field3: z.boolean().optional(),
|
|
231
|
+
createdAt: z.string().datetime(),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const ErrorResponseSchema = z.object({
|
|
235
|
+
error: z.string(),
|
|
236
|
+
message: z.string(),
|
|
237
|
+
details: z.array(z.string()).optional(),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Mock server
|
|
241
|
+
const server = setupServer();
|
|
242
|
+
|
|
243
|
+
beforeAll(() => server.listen());
|
|
244
|
+
afterEach(() => server.resetHandlers());
|
|
245
|
+
afterAll(() => server.close());
|
|
246
|
+
|
|
247
|
+
describe('Create Resource Contract', () => {
|
|
248
|
+
it('should send request matching contract', async () => {
|
|
249
|
+
let capturedRequest: any;
|
|
250
|
+
|
|
251
|
+
server.use(
|
|
252
|
+
rest.post('/api/v1/resource', async (req, res, ctx) => {
|
|
253
|
+
capturedRequest = await req.json();
|
|
254
|
+
|
|
255
|
+
// Validate request matches contract
|
|
256
|
+
const result = CreateResourceRequestSchema.safeParse(capturedRequest);
|
|
257
|
+
expect(result.success).toBe(true);
|
|
258
|
+
|
|
259
|
+
return res(
|
|
260
|
+
ctx.status(201),
|
|
261
|
+
ctx.json({
|
|
262
|
+
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
263
|
+
field1: capturedRequest.field1,
|
|
264
|
+
field2: capturedRequest.field2,
|
|
265
|
+
field3: capturedRequest.field3 ?? false,
|
|
266
|
+
createdAt: new Date().toISOString(),
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const result = await createResource({
|
|
273
|
+
field1: 'test value',
|
|
274
|
+
field2: 42,
|
|
275
|
+
field3: true,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Verify request contract
|
|
279
|
+
expect(capturedRequest).toMatchObject({
|
|
280
|
+
field1: 'test value',
|
|
281
|
+
field2: 42,
|
|
282
|
+
field3: true,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Verify response contract
|
|
286
|
+
const responseValidation = CreateResourceResponseSchema.safeParse(result);
|
|
287
|
+
expect(responseValidation.success).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle error response contract', async () => {
|
|
291
|
+
server.use(
|
|
292
|
+
rest.post('/api/v1/resource', (req, res, ctx) => {
|
|
293
|
+
return res(
|
|
294
|
+
ctx.status(400),
|
|
295
|
+
ctx.json({
|
|
296
|
+
error: 'ValidationError',
|
|
297
|
+
message: 'field1 is required',
|
|
298
|
+
details: ['field1 must not be empty'],
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
createResource({
|
|
306
|
+
field1: '',
|
|
307
|
+
field2: 42,
|
|
308
|
+
})
|
|
309
|
+
).rejects.toThrow();
|
|
310
|
+
|
|
311
|
+
// Verify error response matches contract
|
|
312
|
+
try {
|
|
313
|
+
await createResource({ field1: '', field2: 42 });
|
|
314
|
+
} catch (error: any) {
|
|
315
|
+
const errorValidation = ErrorResponseSchema.safeParse(error.response.data);
|
|
316
|
+
expect(errorValidation.success).toBe(true);
|
|
317
|
+
expect(error.response.status).toBe(400);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should reject response with invalid schema', async () => {
|
|
322
|
+
server.use(
|
|
323
|
+
rest.post('/api/v1/resource', (req, res, ctx) => {
|
|
324
|
+
return res(
|
|
325
|
+
ctx.status(201),
|
|
326
|
+
ctx.json({
|
|
327
|
+
id: 'not-a-uuid', // Invalid UUID
|
|
328
|
+
field1: 123, // Wrong type
|
|
329
|
+
// Missing field2
|
|
330
|
+
})
|
|
331
|
+
);
|
|
332
|
+
})
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
await expect(
|
|
336
|
+
createResource({
|
|
337
|
+
field1: 'test',
|
|
338
|
+
field2: 42,
|
|
339
|
+
})
|
|
340
|
+
).rejects.toThrow('Schema validation failed');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('validates response headers', async () => {
|
|
344
|
+
let responseHeaders: Headers;
|
|
345
|
+
|
|
346
|
+
server.use(
|
|
347
|
+
rest.post('/api/v1/resource', (req, res, ctx) => {
|
|
348
|
+
return res(
|
|
349
|
+
ctx.status(201),
|
|
350
|
+
ctx.set('Content-Type', 'application/json'),
|
|
351
|
+
ctx.json({
|
|
352
|
+
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
353
|
+
field1: 'test',
|
|
354
|
+
field2: 42,
|
|
355
|
+
createdAt: new Date().toISOString(),
|
|
356
|
+
})
|
|
357
|
+
);
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const response = await fetch('/api/v1/resource', {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
body: JSON.stringify({ field1: 'test', field2: 42 }),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe('Schema Validation Utilities', () => {
|
|
371
|
+
it('validates UUID format', () => {
|
|
372
|
+
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
|
373
|
+
const result = z.string().uuid().safeParse(validUuid);
|
|
374
|
+
expect(result.success).toBe(true);
|
|
375
|
+
|
|
376
|
+
const invalidUuid = 'not-a-uuid';
|
|
377
|
+
const invalidResult = z.string().uuid().safeParse(invalidUuid);
|
|
378
|
+
expect(invalidResult.success).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('validates ISO 8601 datetime', () => {
|
|
382
|
+
const validDate = '2025-01-17T10:00:00Z';
|
|
383
|
+
const result = z.string().datetime().safeParse(validDate);
|
|
384
|
+
expect(result.success).toBe(true);
|
|
385
|
+
|
|
386
|
+
const invalidDate = '2025-01-17'; // Missing time
|
|
387
|
+
const invalidResult = z.string().datetime().safeParse(invalidDate);
|
|
388
|
+
expect(invalidResult.success).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Run**:
|
|
394
|
+
```bash
|
|
395
|
+
npm test -- tests/contract/{기능명}.contract.test.ts
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
### React Native (TypeScript + Axios + MockAdapter)
|
|
401
|
+
|
|
402
|
+
**File**: `__tests__/contract/{기능명}.contract.test.ts`
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
import axios from 'axios';
|
|
406
|
+
import MockAdapter from 'axios-mock-adapter';
|
|
407
|
+
import { z } from 'zod';
|
|
408
|
+
import { ApiService } from '@/services/api';
|
|
409
|
+
|
|
410
|
+
const mock = new MockAdapter(axios);
|
|
411
|
+
|
|
412
|
+
const ResponseSchema = z.object({
|
|
413
|
+
id: z.string().uuid(),
|
|
414
|
+
field1: z.string(),
|
|
415
|
+
field2: z.number(),
|
|
416
|
+
createdAt: z.string().datetime(),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('Create Resource Contract (React Native)', () => {
|
|
420
|
+
beforeEach(() => {
|
|
421
|
+
mock.reset();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('should match API contract', async () => {
|
|
425
|
+
const requestBody = {
|
|
426
|
+
field1: 'test value',
|
|
427
|
+
field2: 42,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const responseBody = {
|
|
431
|
+
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
432
|
+
field1: 'test value',
|
|
433
|
+
field2: 42,
|
|
434
|
+
createdAt: '2025-01-17T10:00:00Z',
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
mock.onPost('/api/v1/resource', requestBody).reply(201, responseBody);
|
|
438
|
+
|
|
439
|
+
const apiService = new ApiService(axios);
|
|
440
|
+
const result = await apiService.createResource(requestBody);
|
|
441
|
+
|
|
442
|
+
// Validate response schema
|
|
443
|
+
const validation = ResponseSchema.safeParse(result);
|
|
444
|
+
expect(validation.success).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Run**:
|
|
450
|
+
```bash
|
|
451
|
+
npm test -- __tests__/contract/
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Pact Consumer Tests
|
|
457
|
+
|
|
458
|
+
### Flutter (dart_pact)
|
|
459
|
+
|
|
460
|
+
**File**: `test/pact/{기능명}_pact_test.dart`
|
|
461
|
+
|
|
462
|
+
```dart
|
|
463
|
+
import 'package:pact_consumer_dart/pact_consumer_dart.dart';
|
|
464
|
+
import 'package:test/test.dart';
|
|
465
|
+
|
|
466
|
+
void main() {
|
|
467
|
+
late PactMockService mockService;
|
|
468
|
+
|
|
469
|
+
setUpAll(() async {
|
|
470
|
+
mockService = PactMockService(
|
|
471
|
+
consumer: 'FrontendApp',
|
|
472
|
+
provider: 'BackendAPI',
|
|
473
|
+
port: 1234,
|
|
474
|
+
);
|
|
475
|
+
await mockService.start();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
tearDownAll(() async {
|
|
479
|
+
await mockService.stop();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('create resource contract', () async {
|
|
483
|
+
await mockService
|
|
484
|
+
.given('user is authenticated')
|
|
485
|
+
.uponReceiving('a request to create resource')
|
|
486
|
+
.withRequest(
|
|
487
|
+
method: 'POST',
|
|
488
|
+
path: '/api/v1/resource',
|
|
489
|
+
headers: {'Authorization': 'Bearer token'},
|
|
490
|
+
body: {
|
|
491
|
+
'field1': 'test value',
|
|
492
|
+
'field2': 42,
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
.willRespondWith(
|
|
496
|
+
status: 201,
|
|
497
|
+
body: {
|
|
498
|
+
'id': Matchers.uuid,
|
|
499
|
+
'field1': Matchers.string('test value'),
|
|
500
|
+
'field2': Matchers.integer(42),
|
|
501
|
+
'createdAt': Matchers.iso8601DateTime,
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
await mockService.run((config) async {
|
|
506
|
+
// Test your API service against mock
|
|
507
|
+
final apiService = ApiService(baseUrl: config.baseUrl);
|
|
508
|
+
final result = await apiService.createResource(
|
|
509
|
+
field1: 'test value',
|
|
510
|
+
field2: 42,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
expect(result.id, isNotEmpty);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// Pact file generated: pacts/FrontendApp-BackendAPI.json
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## CI/CD Integration
|
|
524
|
+
|
|
525
|
+
```yaml
|
|
526
|
+
# .github/workflows/contract-tests.yml
|
|
527
|
+
name: Frontend Contract Tests
|
|
528
|
+
|
|
529
|
+
on: [pull_request]
|
|
530
|
+
|
|
531
|
+
jobs:
|
|
532
|
+
contract-tests:
|
|
533
|
+
runs-on: ubuntu-latest
|
|
534
|
+
|
|
535
|
+
steps:
|
|
536
|
+
- uses: actions/checkout@v2
|
|
537
|
+
|
|
538
|
+
- name: Setup Flutter
|
|
539
|
+
uses: subosito/flutter-action@v2
|
|
540
|
+
with:
|
|
541
|
+
flutter-version: '3.24.0'
|
|
542
|
+
|
|
543
|
+
- name: Run contract tests
|
|
544
|
+
run: flutter test test/contract/
|
|
545
|
+
|
|
546
|
+
- name: Run Pact tests
|
|
547
|
+
run: flutter test test/pact/
|
|
548
|
+
|
|
549
|
+
- name: Publish Pact
|
|
550
|
+
if: success()
|
|
551
|
+
run: |
|
|
552
|
+
flutter pub global activate pact_broker_cli
|
|
553
|
+
pact-broker publish pacts/ \
|
|
554
|
+
--consumer-app-version=${{ github.sha }} \
|
|
555
|
+
--broker-base-url=${{ secrets.PACT_BROKER_URL }}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
## Best Practices
|
|
561
|
+
|
|
562
|
+
1. **Mock 서버 활용**
|
|
563
|
+
- ✅ 백엔드 없이 독립적 테스트
|
|
564
|
+
- ✅ 계약 위반 시 즉시 감지
|
|
565
|
+
|
|
566
|
+
2. **Schema Validation**
|
|
567
|
+
- ✅ Zod, JSON Schema로 응답 검증
|
|
568
|
+
- ✅ 타입 안정성 보장
|
|
569
|
+
|
|
570
|
+
3. **Consumer-Driven**
|
|
571
|
+
- ✅ Frontend 요구사항 먼저 정의
|
|
572
|
+
- ✅ Pact 파일로 백엔드 팀과 공유
|
|
573
|
+
|
|
574
|
+
4. **CI/CD 자동화**
|
|
575
|
+
- ✅ PR마다 Contract 검증
|
|
576
|
+
- ✅ Pact Broker로 중앙 관리
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
## Next Steps
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
# 1. Contract 테스트 작성
|
|
584
|
+
vibe contract "{기능명}" --frontend
|
|
585
|
+
|
|
586
|
+
# 2. Mock 서버로 개발
|
|
587
|
+
flutter test test/contract/ --watch
|
|
588
|
+
|
|
589
|
+
# 3. Pact 생성 및 발행
|
|
590
|
+
flutter test test/pact/
|
|
591
|
+
|
|
592
|
+
# 4. 백엔드와 계약 검증
|
|
593
|
+
vibe verify "{기능명}" --contract
|
|
594
|
+
```
|