red64-cli 0.5.0 → 0.6.0
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/README.md +64 -58
- package/dist/components/screens/StartScreen.d.ts.map +1 -1
- package/dist/components/screens/StartScreen.js +2 -2
- package/dist/components/screens/StartScreen.js.map +1 -1
- package/dist/services/AgentInvoker.js +4 -4
- package/dist/services/AgentInvoker.js.map +1 -1
- package/dist/services/ClaudeHealthCheck.d.ts +5 -0
- package/dist/services/ClaudeHealthCheck.d.ts.map +1 -1
- package/dist/services/ClaudeHealthCheck.js +43 -5
- package/dist/services/ClaudeHealthCheck.js.map +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/index.js.map +1 -1
- package/framework/stacks/c/code-quality.md +326 -0
- package/framework/stacks/c/coding-style.md +347 -0
- package/framework/stacks/c/conventions.md +513 -0
- package/framework/stacks/c/error-handling.md +350 -0
- package/framework/stacks/c/feedback.md +158 -0
- package/framework/stacks/c/memory-safety.md +408 -0
- package/framework/stacks/c/tech.md +122 -0
- package/framework/stacks/c/testing.md +472 -0
- package/framework/stacks/cpp/code-quality.md +282 -0
- package/framework/stacks/cpp/coding-style.md +363 -0
- package/framework/stacks/cpp/conventions.md +420 -0
- package/framework/stacks/cpp/error-handling.md +264 -0
- package/framework/stacks/cpp/feedback.md +104 -0
- package/framework/stacks/cpp/memory-safety.md +351 -0
- package/framework/stacks/cpp/tech.md +160 -0
- package/framework/stacks/cpp/testing.md +323 -0
- package/framework/stacks/java/code-quality.md +357 -0
- package/framework/stacks/java/coding-style.md +400 -0
- package/framework/stacks/java/conventions.md +437 -0
- package/framework/stacks/java/error-handling.md +408 -0
- package/framework/stacks/java/feedback.md +180 -0
- package/framework/stacks/java/tech.md +126 -0
- package/framework/stacks/java/testing.md +485 -0
- package/framework/stacks/javascript/async-patterns.md +216 -0
- package/framework/stacks/javascript/code-quality.md +182 -0
- package/framework/stacks/javascript/coding-style.md +293 -0
- package/framework/stacks/javascript/conventions.md +268 -0
- package/framework/stacks/javascript/error-handling.md +216 -0
- package/framework/stacks/javascript/feedback.md +80 -0
- package/framework/stacks/javascript/tech.md +114 -0
- package/framework/stacks/javascript/testing.md +209 -0
- package/framework/stacks/loco/code-quality.md +156 -0
- package/framework/stacks/loco/coding-style.md +247 -0
- package/framework/stacks/loco/error-handling.md +225 -0
- package/framework/stacks/loco/feedback.md +35 -0
- package/framework/stacks/loco/loco.md +342 -0
- package/framework/stacks/loco/structure.md +193 -0
- package/framework/stacks/loco/tech.md +129 -0
- package/framework/stacks/loco/testing.md +211 -0
- package/framework/stacks/rust/code-quality.md +370 -0
- package/framework/stacks/rust/coding-style.md +475 -0
- package/framework/stacks/rust/conventions.md +430 -0
- package/framework/stacks/rust/error-handling.md +399 -0
- package/framework/stacks/rust/feedback.md +152 -0
- package/framework/stacks/rust/memory-safety.md +398 -0
- package/framework/stacks/rust/tech.md +121 -0
- package/framework/stacks/rust/testing.md +528 -0
- package/package.json +14 -2
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
# Testing Patterns
|
|
2
|
+
|
|
3
|
+
Comprehensive JUnit 5 testing patterns for Java 21+ with Spring Boot, Mockito, AssertJ, and Testcontainers.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Fast feedback**: Unit tests run in milliseconds with no I/O
|
|
10
|
+
- **Production parity**: Integration tests use real databases via Testcontainers
|
|
11
|
+
- **Readable tests**: Each test tells a story with arrange-act-assert and `@DisplayName`
|
|
12
|
+
- **Test slices**: Use Spring test slices (`@WebMvcTest`, `@DataJpaTest`) to keep tests fast and focused
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Test Organization
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
src/test/java/com/example/
|
|
20
|
+
unit/
|
|
21
|
+
service/
|
|
22
|
+
UserServiceTest.java
|
|
23
|
+
PaymentServiceTest.java
|
|
24
|
+
util/
|
|
25
|
+
SlugGeneratorTest.java
|
|
26
|
+
integration/
|
|
27
|
+
api/
|
|
28
|
+
UserControllerIntegrationTest.java
|
|
29
|
+
repository/
|
|
30
|
+
UserRepositoryIntegrationTest.java
|
|
31
|
+
architecture/
|
|
32
|
+
ArchitectureTest.java
|
|
33
|
+
support/
|
|
34
|
+
TestContainersConfig.java
|
|
35
|
+
TestDataFactory.java
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Pattern**: Mirror `src/main/java` structure. Suffix all test classes with `Test`.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## JUnit 5 Fundamentals
|
|
43
|
+
|
|
44
|
+
### @Test and @DisplayName
|
|
45
|
+
|
|
46
|
+
```java
|
|
47
|
+
class UserServiceTest {
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
@DisplayName("should create user when email is unique")
|
|
51
|
+
void createUser_uniqueEmail_returnsUser() {
|
|
52
|
+
// Arrange
|
|
53
|
+
var request = new CreateUserRequest("alice@example.com", "Alice");
|
|
54
|
+
when(userRepository.findByEmail("alice@example.com")).thenReturn(Optional.empty());
|
|
55
|
+
when(userRepository.save(any(User.class))).thenAnswer(inv -> {
|
|
56
|
+
var user = inv.getArgument(0, User.class);
|
|
57
|
+
user.setId(1L);
|
|
58
|
+
return user;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
var result = userService.createUser(request);
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
assertThat(result.getEmail()).isEqualTo("alice@example.com");
|
|
66
|
+
assertThat(result.getId()).isNotNull();
|
|
67
|
+
verify(userRepository).save(any(User.class));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
@DisplayName("should throw ConflictException when email already exists")
|
|
72
|
+
void createUser_duplicateEmail_throwsConflict() {
|
|
73
|
+
var request = new CreateUserRequest("taken@example.com", "Bob");
|
|
74
|
+
when(userRepository.findByEmail("taken@example.com"))
|
|
75
|
+
.thenReturn(Optional.of(new User("taken@example.com", "Existing")));
|
|
76
|
+
|
|
77
|
+
assertThatThrownBy(() -> userService.createUser(request))
|
|
78
|
+
.isInstanceOf(ConflictException.class)
|
|
79
|
+
.hasMessageContaining("already registered");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### @Nested for Grouping
|
|
85
|
+
|
|
86
|
+
```java
|
|
87
|
+
class PostServiceTest {
|
|
88
|
+
|
|
89
|
+
@Nested
|
|
90
|
+
@DisplayName("publish()")
|
|
91
|
+
class Publish {
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
@DisplayName("should publish draft post")
|
|
95
|
+
void draft_publishes() { ... }
|
|
96
|
+
|
|
97
|
+
@Test
|
|
98
|
+
@DisplayName("should throw when post is already published")
|
|
99
|
+
void alreadyPublished_throwsConflict() { ... }
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
@DisplayName("should throw when user is not the author")
|
|
103
|
+
void wrongAuthor_throwsForbidden() { ... }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Nested
|
|
107
|
+
@DisplayName("delete()")
|
|
108
|
+
class Delete {
|
|
109
|
+
|
|
110
|
+
@Test
|
|
111
|
+
@DisplayName("should soft-delete post")
|
|
112
|
+
void validPost_softDeletes() { ... }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### @ParameterizedTest
|
|
118
|
+
|
|
119
|
+
```java
|
|
120
|
+
class EmailValidatorTest {
|
|
121
|
+
|
|
122
|
+
@ParameterizedTest(name = "\"{0}\" should be {1}")
|
|
123
|
+
@CsvSource({
|
|
124
|
+
"user@example.com, true",
|
|
125
|
+
"user@sub.example.com, true",
|
|
126
|
+
"invalid, false",
|
|
127
|
+
"'', false",
|
|
128
|
+
"@example.com, false",
|
|
129
|
+
})
|
|
130
|
+
@DisplayName("should validate email format")
|
|
131
|
+
void validateEmail(String email, boolean expectedValid) {
|
|
132
|
+
assertThat(EmailValidator.isValid(email)).isEqualTo(expectedValid);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@ParameterizedTest
|
|
136
|
+
@MethodSource("rolePermissions")
|
|
137
|
+
@DisplayName("should check role permissions")
|
|
138
|
+
void checkPermission(Role role, Permission permission, boolean expected) {
|
|
139
|
+
assertThat(role.hasPermission(permission)).isEqualTo(expected);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
static Stream<Arguments> rolePermissions() {
|
|
143
|
+
return Stream.of(
|
|
144
|
+
Arguments.of(Role.ADMIN, Permission.DELETE, true),
|
|
145
|
+
Arguments.of(Role.USER, Permission.DELETE, false),
|
|
146
|
+
Arguments.of(Role.USER, Permission.READ, true)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## AssertJ Fluent Assertions
|
|
155
|
+
|
|
156
|
+
```java
|
|
157
|
+
// GOOD: AssertJ -- fluent, readable, great error messages
|
|
158
|
+
assertThat(user.getEmail()).isEqualTo("alice@example.com");
|
|
159
|
+
assertThat(users).hasSize(3)
|
|
160
|
+
.extracting(User::getEmail)
|
|
161
|
+
.containsExactly("alice@example.com", "bob@example.com", "carol@example.com");
|
|
162
|
+
|
|
163
|
+
assertThat(result).isNotNull()
|
|
164
|
+
.satisfies(r -> {
|
|
165
|
+
assertThat(r.getStatus()).isEqualTo(Status.ACTIVE);
|
|
166
|
+
assertThat(r.getCreatedAt()).isBeforeOrEqualTo(Instant.now());
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assertThatThrownBy(() -> service.getUser(999L))
|
|
170
|
+
.isInstanceOf(NotFoundException.class)
|
|
171
|
+
.hasMessageContaining("User not found");
|
|
172
|
+
|
|
173
|
+
assertThat(optionalUser).isPresent()
|
|
174
|
+
.get()
|
|
175
|
+
.extracting(User::getName)
|
|
176
|
+
.isEqualTo("Alice");
|
|
177
|
+
|
|
178
|
+
// BAD: JUnit assertions -- less readable, worse error messages
|
|
179
|
+
assertEquals("alice@example.com", user.getEmail());
|
|
180
|
+
assertTrue(users.size() == 3);
|
|
181
|
+
assertNotNull(result);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Mockito
|
|
187
|
+
|
|
188
|
+
### Setup with @Mock and @InjectMocks
|
|
189
|
+
|
|
190
|
+
```java
|
|
191
|
+
@ExtendWith(MockitoExtension.class)
|
|
192
|
+
class UserServiceTest {
|
|
193
|
+
|
|
194
|
+
@Mock
|
|
195
|
+
private UserRepository userRepository;
|
|
196
|
+
|
|
197
|
+
@Mock
|
|
198
|
+
private PasswordEncoder passwordEncoder;
|
|
199
|
+
|
|
200
|
+
@InjectMocks
|
|
201
|
+
private UserService userService;
|
|
202
|
+
|
|
203
|
+
@Test
|
|
204
|
+
void getUser_existing_returnsUser() {
|
|
205
|
+
// given
|
|
206
|
+
var user = new User("alice@example.com", "Alice");
|
|
207
|
+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
|
208
|
+
|
|
209
|
+
// when
|
|
210
|
+
var result = userService.getUser(1L);
|
|
211
|
+
|
|
212
|
+
// then
|
|
213
|
+
assertThat(result.getEmail()).isEqualTo("alice@example.com");
|
|
214
|
+
verify(userRepository).findById(1L);
|
|
215
|
+
verifyNoMoreInteractions(userRepository);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### BDD Style (given/when/then)
|
|
221
|
+
|
|
222
|
+
```java
|
|
223
|
+
import static org.mockito.BDDMockito.*;
|
|
224
|
+
|
|
225
|
+
@Test
|
|
226
|
+
void createUser_hashesPassword() {
|
|
227
|
+
// given
|
|
228
|
+
given(passwordEncoder.encode("rawPassword")).willReturn("hashedPassword");
|
|
229
|
+
given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
|
|
230
|
+
|
|
231
|
+
// when
|
|
232
|
+
var user = userService.createUser(new CreateUserRequest("a@b.com", "Alice", "rawPassword"));
|
|
233
|
+
|
|
234
|
+
// then
|
|
235
|
+
then(passwordEncoder).should().encode("rawPassword");
|
|
236
|
+
assertThat(user.getHashedPassword()).isEqualTo("hashedPassword");
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Argument Captors
|
|
241
|
+
|
|
242
|
+
```java
|
|
243
|
+
@Test
|
|
244
|
+
void createUser_savesCorrectEntity() {
|
|
245
|
+
var captor = ArgumentCaptor.forClass(User.class);
|
|
246
|
+
given(userRepository.save(captor.capture())).willAnswer(inv -> inv.getArgument(0));
|
|
247
|
+
|
|
248
|
+
userService.createUser(new CreateUserRequest("a@b.com", "Alice", "pw"));
|
|
249
|
+
|
|
250
|
+
var saved = captor.getValue();
|
|
251
|
+
assertThat(saved.getEmail()).isEqualTo("a@b.com");
|
|
252
|
+
assertThat(saved.getName()).isEqualTo("Alice");
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Testcontainers (Integration Tests)
|
|
259
|
+
|
|
260
|
+
### Shared Container Configuration
|
|
261
|
+
|
|
262
|
+
```java
|
|
263
|
+
// src/test/java/com/example/support/TestContainersConfig.java
|
|
264
|
+
@TestConfiguration(proxyBeanMethods = false)
|
|
265
|
+
public class TestContainersConfig {
|
|
266
|
+
|
|
267
|
+
@Bean
|
|
268
|
+
@ServiceConnection
|
|
269
|
+
PostgreSQLContainer<?> postgresContainer() {
|
|
270
|
+
return new PostgreSQLContainer<>("postgres:16-alpine")
|
|
271
|
+
.withDatabaseName("testdb")
|
|
272
|
+
.withUsername("test")
|
|
273
|
+
.withPassword("test");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@Bean
|
|
277
|
+
@ServiceConnection
|
|
278
|
+
GenericContainer<?> redisContainer() {
|
|
279
|
+
return new GenericContainer<>("redis:7-alpine")
|
|
280
|
+
.withExposedPorts(6379);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Integration Test Base
|
|
286
|
+
|
|
287
|
+
```java
|
|
288
|
+
@SpringBootTest
|
|
289
|
+
@Import(TestContainersConfig.class)
|
|
290
|
+
@Transactional // rolls back after each test
|
|
291
|
+
abstract class IntegrationTestBase {
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Repository Integration Test
|
|
296
|
+
|
|
297
|
+
```java
|
|
298
|
+
class UserRepositoryIntegrationTest extends IntegrationTestBase {
|
|
299
|
+
|
|
300
|
+
@Autowired
|
|
301
|
+
private UserRepository userRepository;
|
|
302
|
+
|
|
303
|
+
@Test
|
|
304
|
+
@DisplayName("should save and find user by email")
|
|
305
|
+
void saveAndFindByEmail() {
|
|
306
|
+
var user = new User("test@example.com", "Test User");
|
|
307
|
+
userRepository.save(user);
|
|
308
|
+
|
|
309
|
+
var found = userRepository.findByEmail("test@example.com");
|
|
310
|
+
|
|
311
|
+
assertThat(found).isPresent()
|
|
312
|
+
.get()
|
|
313
|
+
.satisfies(u -> {
|
|
314
|
+
assertThat(u.getId()).isNotNull();
|
|
315
|
+
assertThat(u.getEmail()).isEqualTo("test@example.com");
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Spring Test Slices
|
|
324
|
+
|
|
325
|
+
### @WebMvcTest (Controller Layer)
|
|
326
|
+
|
|
327
|
+
```java
|
|
328
|
+
@WebMvcTest(UserController.class)
|
|
329
|
+
class UserControllerTest {
|
|
330
|
+
|
|
331
|
+
@Autowired
|
|
332
|
+
private MockMvc mockMvc;
|
|
333
|
+
|
|
334
|
+
@MockitoBean
|
|
335
|
+
private UserService userService;
|
|
336
|
+
|
|
337
|
+
@Test
|
|
338
|
+
@DisplayName("GET /api/v1/users/{id} returns user")
|
|
339
|
+
void getUser_existing_returns200() throws Exception {
|
|
340
|
+
var user = new UserResponse(1L, "alice@example.com", "Alice", Instant.now());
|
|
341
|
+
when(userService.getUser(1L)).thenReturn(user);
|
|
342
|
+
|
|
343
|
+
mockMvc.perform(get("/api/v1/users/1"))
|
|
344
|
+
.andExpect(status().isOk())
|
|
345
|
+
.andExpect(jsonPath("$.email").value("alice@example.com"))
|
|
346
|
+
.andExpect(jsonPath("$.name").value("Alice"));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
@Test
|
|
350
|
+
@DisplayName("GET /api/v1/users/{id} returns 404 when not found")
|
|
351
|
+
void getUser_notFound_returns404() throws Exception {
|
|
352
|
+
when(userService.getUser(999L)).thenThrow(new NotFoundException("User", 999L));
|
|
353
|
+
|
|
354
|
+
mockMvc.perform(get("/api/v1/users/999"))
|
|
355
|
+
.andExpect(status().isNotFound())
|
|
356
|
+
.andExpect(jsonPath("$.title").value("NOT_FOUND"));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
@Test
|
|
360
|
+
@DisplayName("POST /api/v1/users validates request body")
|
|
361
|
+
void createUser_invalidBody_returns422() throws Exception {
|
|
362
|
+
mockMvc.perform(post("/api/v1/users")
|
|
363
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
364
|
+
.content("""
|
|
365
|
+
{"email": "", "name": ""}
|
|
366
|
+
"""))
|
|
367
|
+
.andExpect(status().isUnprocessableEntity());
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### @DataJpaTest (Repository Layer)
|
|
373
|
+
|
|
374
|
+
```java
|
|
375
|
+
@DataJpaTest
|
|
376
|
+
@Import(TestContainersConfig.class)
|
|
377
|
+
class UserRepositoryTest {
|
|
378
|
+
|
|
379
|
+
@Autowired
|
|
380
|
+
private UserRepository userRepository;
|
|
381
|
+
|
|
382
|
+
@Autowired
|
|
383
|
+
private TestEntityManager entityManager;
|
|
384
|
+
|
|
385
|
+
@Test
|
|
386
|
+
@DisplayName("should find active users by role")
|
|
387
|
+
void findActiveByRole() {
|
|
388
|
+
entityManager.persist(new User("admin@test.com", "Admin", Role.ADMIN, true));
|
|
389
|
+
entityManager.persist(new User("user@test.com", "User", Role.USER, true));
|
|
390
|
+
entityManager.persist(new User("inactive@test.com", "Inactive", Role.ADMIN, false));
|
|
391
|
+
entityManager.flush();
|
|
392
|
+
|
|
393
|
+
var admins = userRepository.findByRoleAndActiveTrue(Role.ADMIN);
|
|
394
|
+
|
|
395
|
+
assertThat(admins).hasSize(1)
|
|
396
|
+
.extracting(User::getEmail)
|
|
397
|
+
.containsExactly("admin@test.com");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## JaCoCo Coverage
|
|
405
|
+
|
|
406
|
+
### Gradle Configuration
|
|
407
|
+
|
|
408
|
+
```kotlin
|
|
409
|
+
// build.gradle.kts
|
|
410
|
+
plugins {
|
|
411
|
+
id("jacoco")
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
jacoco {
|
|
415
|
+
toolVersion = "0.8.12"
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
tasks.jacocoTestReport {
|
|
419
|
+
dependsOn(tasks.test)
|
|
420
|
+
reports {
|
|
421
|
+
xml.required.set(true) // for SonarQube
|
|
422
|
+
html.required.set(true) // for humans
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
tasks.jacocoTestCoverageVerification {
|
|
427
|
+
violationRules {
|
|
428
|
+
rule {
|
|
429
|
+
limit {
|
|
430
|
+
minimum = "0.80".toBigDecimal()
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
rule {
|
|
434
|
+
element = "CLASS"
|
|
435
|
+
excludes = listOf(
|
|
436
|
+
"*.dto.*",
|
|
437
|
+
"*.config.*",
|
|
438
|
+
"*.Application"
|
|
439
|
+
)
|
|
440
|
+
limit {
|
|
441
|
+
minimum = "0.80".toBigDecimal()
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
tasks.check {
|
|
448
|
+
dependsOn(tasks.jacocoTestCoverageVerification)
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
./gradlew test jacocoTestReport # Generate coverage report
|
|
454
|
+
./gradlew jacocoTestCoverageVerification # Enforce thresholds
|
|
455
|
+
# Report: build/reports/jacoco/test/html/index.html
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Test Naming Convention
|
|
461
|
+
|
|
462
|
+
| Pattern | Example |
|
|
463
|
+
|---|---|
|
|
464
|
+
| `methodUnderTest_scenario_expectedBehavior` | `createUser_duplicateEmail_throwsConflict` |
|
|
465
|
+
| `@DisplayName` for human-readable output | `"should throw ConflictException when email exists"` |
|
|
466
|
+
| `@Nested` class name = method being tested | `class Publish { ... }` |
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## Anti-Patterns
|
|
471
|
+
|
|
472
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
473
|
+
|---|---|---|
|
|
474
|
+
| No `@DisplayName` | Test output is unreadable | Always add descriptive display names |
|
|
475
|
+
| JUnit `assertEquals` over AssertJ | Worse error messages, less fluent | Use `assertThat()` from AssertJ |
|
|
476
|
+
| H2 for integration tests | Behavior differs from PostgreSQL | Testcontainers with real PostgreSQL |
|
|
477
|
+
| `@SpringBootTest` for unit tests | Slow startup, tests entire context | `@ExtendWith(MockitoExtension.class)` |
|
|
478
|
+
| Testing implementation, not behavior | Fragile tests that break on refactor | Test public API and outcomes |
|
|
479
|
+
| No test slices | Full context loaded for every test | `@WebMvcTest`, `@DataJpaTest` |
|
|
480
|
+
| Shared mutable state between tests | Order-dependent, flaky tests | `@Transactional` rollback, fresh fixtures |
|
|
481
|
+
| `@Autowired` fields in unit tests | Requires Spring context | `@Mock` + `@InjectMocks` |
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
_Tests document behavior. Each test should read as a specification: given this state, when this action, then this outcome._
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Async Patterns
|
|
2
|
+
|
|
3
|
+
Modern asynchronous programming patterns for Node.js applications.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **async/await everywhere**: Promises are the foundation; async/await is the syntax
|
|
10
|
+
- **Concurrent by default**: Run independent operations in parallel, not sequentially
|
|
11
|
+
- **Cancellable operations**: Use AbortController for timeouts and user cancellation
|
|
12
|
+
- **Backpressure-aware**: Use streams for large data; do not load everything into memory
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Promise Combinators
|
|
17
|
+
|
|
18
|
+
### Promise.all -- All Must Succeed
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
// GOOD: Run independent operations concurrently
|
|
22
|
+
async function getUserDashboard(userId) {
|
|
23
|
+
const [user, posts, notifications] = await Promise.all([
|
|
24
|
+
userService.getUser(userId),
|
|
25
|
+
postService.getUserPosts(userId),
|
|
26
|
+
notificationService.getUnread(userId),
|
|
27
|
+
]);
|
|
28
|
+
return { user, posts, notifications };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// BAD: Sequential when operations are independent (2-3x slower)
|
|
32
|
+
async function getUserDashboard(userId) {
|
|
33
|
+
const user = await userService.getUser(userId);
|
|
34
|
+
const posts = await postService.getUserPosts(userId);
|
|
35
|
+
const notifications = await notificationService.getUnread(userId);
|
|
36
|
+
return { user, posts, notifications };
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Promise.allSettled -- Collect All Results
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
// When partial failures are acceptable
|
|
44
|
+
async function sendNotifications(userIds) {
|
|
45
|
+
const results = await Promise.allSettled(
|
|
46
|
+
userIds.map(id => notificationService.send(id)),
|
|
47
|
+
);
|
|
48
|
+
const succeeded = results.filter(r => r.status === 'fulfilled');
|
|
49
|
+
const failed = results.filter(r => r.status === 'rejected');
|
|
50
|
+
logger.info({ sent: succeeded.length, failed: failed.length }, 'notifications_sent');
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### When to Use Each
|
|
55
|
+
|
|
56
|
+
| Combinator | Use Case | Failure Behavior |
|
|
57
|
+
|-----------|----------|-----------------|
|
|
58
|
+
| `Promise.all` | All tasks must succeed | Rejects on first failure |
|
|
59
|
+
| `Promise.allSettled` | Collect all outcomes (partial failure OK) | Never rejects |
|
|
60
|
+
| `Promise.race` | First to settle (timeouts) | Settles with first result |
|
|
61
|
+
| `Promise.any` | First to succeed (fallbacks) | Rejects only if all fail |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## AbortController
|
|
66
|
+
|
|
67
|
+
### AbortSignal.timeout() -- Built-in Timeout
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
// Node.js 22+: Simpler timeout pattern
|
|
71
|
+
async function fetchUser(userId) {
|
|
72
|
+
const response = await fetch(`/api/users/${userId}`, {
|
|
73
|
+
signal: AbortSignal.timeout(5000),
|
|
74
|
+
});
|
|
75
|
+
return response.json();
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### AbortSignal.any() -- Combining Signals
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
async function fetchData(url, userSignal) {
|
|
83
|
+
const response = await fetch(url, {
|
|
84
|
+
signal: AbortSignal.any([userSignal, AbortSignal.timeout(10000)]),
|
|
85
|
+
});
|
|
86
|
+
return response.json();
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Making Custom APIs Abortable
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
async function processLargeDataset(items, { signal } = {}) {
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const item of items) {
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
const result = await processItem(item);
|
|
98
|
+
results.push(result);
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Stream Patterns
|
|
107
|
+
|
|
108
|
+
### Readable Streams with async iteration
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
import { createReadStream } from 'node:fs';
|
|
112
|
+
import { createInterface } from 'node:readline';
|
|
113
|
+
|
|
114
|
+
// GOOD: Process large files line by line (constant memory)
|
|
115
|
+
async function processLogFile(filePath) {
|
|
116
|
+
const fileStream = createReadStream(filePath);
|
|
117
|
+
const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
118
|
+
let lineCount = 0;
|
|
119
|
+
for await (const line of rl) {
|
|
120
|
+
if (line.includes('ERROR')) lineCount++;
|
|
121
|
+
}
|
|
122
|
+
return lineCount;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// BAD: Loading entire file into memory
|
|
126
|
+
async function processLogFile(filePath) {
|
|
127
|
+
const content = await readFile(filePath, 'utf-8'); // May OOM on large files
|
|
128
|
+
return content.split('\n').filter(line => line.includes('ERROR')).length;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Transform Streams (pipeline)
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
import { pipeline } from 'node:stream/promises';
|
|
136
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
137
|
+
import { createGzip } from 'node:zlib';
|
|
138
|
+
|
|
139
|
+
async function compressFile(inputPath, outputPath) {
|
|
140
|
+
await pipeline(
|
|
141
|
+
createReadStream(inputPath),
|
|
142
|
+
createGzip(),
|
|
143
|
+
createWriteStream(outputPath),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Worker Threads
|
|
151
|
+
|
|
152
|
+
### CPU-Intensive Tasks
|
|
153
|
+
|
|
154
|
+
```javascript
|
|
155
|
+
import { Worker } from 'node:worker_threads';
|
|
156
|
+
|
|
157
|
+
function hashPassword(password, salt) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const worker = new Worker('./src/workers/hash-worker.js', {
|
|
160
|
+
workerData: { password, salt },
|
|
161
|
+
});
|
|
162
|
+
worker.on('message', resolve);
|
|
163
|
+
worker.on('error', reject);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### When to Use Worker Threads
|
|
169
|
+
|
|
170
|
+
| Task | Use Worker? | Reason |
|
|
171
|
+
|------|------------|--------|
|
|
172
|
+
| JSON parsing (small) | No | Fast enough on main thread |
|
|
173
|
+
| Image processing | Yes | CPU-bound, blocks event loop |
|
|
174
|
+
| Cryptographic hashing | Yes | CPU-intensive |
|
|
175
|
+
| Database queries | No | Already async I/O |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Concurrency Control
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
// Simple concurrency limiter (or use p-limit package)
|
|
183
|
+
async function mapWithConcurrency(items, fn, concurrency = 5) {
|
|
184
|
+
const results = [];
|
|
185
|
+
const executing = new Set();
|
|
186
|
+
for (const item of items) {
|
|
187
|
+
const promise = fn(item).then(result => {
|
|
188
|
+
executing.delete(promise);
|
|
189
|
+
return result;
|
|
190
|
+
});
|
|
191
|
+
executing.add(promise);
|
|
192
|
+
results.push(promise);
|
|
193
|
+
if (executing.size >= concurrency) {
|
|
194
|
+
await Promise.race(executing);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return Promise.all(results);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Anti-Patterns
|
|
204
|
+
|
|
205
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| Unhandled promise rejection | Crashes process | Always `catch` or use process handler |
|
|
208
|
+
| `await` inside `forEach` | Does not wait, runs all at once | Use `for...of` or `Promise.all` with `.map()` |
|
|
209
|
+
| `new Promise(async ...)` | Unnecessary wrapper | Return the async call directly |
|
|
210
|
+
| Mixing callbacks and promises | Inconsistent error handling | Convert callbacks with `util.promisify()` |
|
|
211
|
+
| Sequential independent awaits | 2-3x slower than necessary | Use `Promise.all` for independent operations |
|
|
212
|
+
| Unbounded parallelism | Overwhelms external services | Use concurrency limiter (p-limit) |
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
_Async code should be simple to read and reason about. Use async/await consistently, run independent work concurrently, and always handle errors._
|